const { expect } = require('chai') const sinon = require('sinon') const tk = require('timekeeper') const Errors = require('../../../../app/src/Features/Errors/Errors') const { ObjectId } = require('mongoose').Types const SandboxedModule = require('sandboxed-module') const { Project } = require('../helpers/models/Project') const MODULE_PATH = '../../../../app/src/Features/Project/ProjectEntityMongoUpdateHandler' describe('ProjectEntityMongoUpdateHandler', function() { beforeEach(function() { this.doc = { _id: ObjectId(), name: 'test-doc.txt', lines: ['hello', 'world'], rev: 1234 } this.docPath = { mongo: 'rootFolder.0.docs.0', fileSystem: '/test-doc.txt' } this.file = { _id: ObjectId(), name: 'something.jpg', linkedFileData: { provider: 'url' }, hash: 'some-hash' } this.filePath = { fileSystem: '/something.png', mongo: 'rootFolder.0.fileRefs.0' } this.subfolder = { _id: ObjectId(), name: 'test-subfolder' } this.subfolderPath = { fileSystem: '/test-folder/test-subfolder', mongo: 'rootFolder.0.folders.0.folders.0' } this.folder = { _id: ObjectId(), name: 'test-folder', folders: [this.subfolder] } this.folderPath = { fileSystem: '/test-folder', mongo: 'rootFolder.0.folders.0' } this.rootFolder = { _id: ObjectId(), folders: [this.folder], docs: [this.doc], fileRefs: [this.file] } this.rootFolderPath = { fileSystem: '/', mongo: 'rootFolder.0' } this.project = { _id: ObjectId(), name: 'project name', rootFolder: [this.rootFolder] } this.logger = { log: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), err() {} } this.Settings = { maxEntitiesPerProject: 100 } this.CooldownManager = {} this.LockManager = { promises: { runWithLock: sinon.spy((namespace, id, runner) => runner()) } } this.FolderModel = sinon.stub() this.ProjectMock = sinon.mock(Project) this.ProjectEntityHandler = { promises: { getAllEntitiesFromProject: sinon.stub() } } this.ProjectLocator = { promises: { findElement: sinon.stub().rejects(new Error('not found')), findElementByPath: sinon.stub().rejects(new Error('not found')) } } this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.rootFolder._id, type: 'folder' }) .resolves({ element: this.rootFolder, path: this.rootFolderPath }) this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.folder._id, type: 'folder' }) .resolves({ element: this.folder, path: this.folderPath, folder: this.rootFolder }) this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.subfolder._id, type: 'folder' }) .resolves({ element: this.subfolder, path: this.subfolderPath, folder: this.folder }) this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.file._id, type: 'file' }) .resolves({ element: this.file, path: this.filePath, folder: this.rootFolder }) this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.doc._id, type: 'doc' }) .resolves({ element: this.doc, path: this.docPath, folder: this.rootFolder }) this.ProjectLocator.promises.findElementByPath .withArgs( sinon.match({ project: this.project, path: '/' }) ) .resolves({ element: this.rootFolder, type: 'folder' }) this.ProjectLocator.promises.findElementByPath .withArgs( sinon.match({ project: this.project, path: '/test-folder' }) ) .resolves({ element: this.folder, type: 'folder' }) this.ProjectLocator.promises.findElementByPath .withArgs( sinon.match({ project: this.project, path: '/test-folder/test-subfolder' }) ) .resolves({ element: this.subfolder, type: 'folder' }) this.ProjectGetter = { promises: { getProjectWithoutLock: sinon .stub() .withArgs(this.project._id) .resolves(this.project), getProjectWithOnlyFolders: sinon.stub().resolves(this.project) } } this.FolderStructureBuilder = { buildFolderStructure: sinon.stub() } this.subject = SandboxedModule.require(MODULE_PATH, { globals: { console: console }, requires: { 'logger-sharelatex': this.logger, 'settings-sharelatex': this.Settings, '../Cooldown/CooldownManager': this.CooldownManager, '../../models/Folder': { Folder: this.FolderModel }, '../../infrastructure/LockManager': this.LockManager, '../../models/Project': { Project }, './ProjectEntityHandler': this.ProjectEntityHandler, './ProjectLocator': this.ProjectLocator, './ProjectGetter': this.ProjectGetter, './FolderStructureBuilder': this.FolderStructureBuilder, // We need to provide Errors here to make instance check work '../Errors/Errors': Errors } }) }) afterEach(function() { this.ProjectMock.restore() }) beforeEach(function() { tk.freeze(Date.now()) }) afterEach(function() { tk.reset() }) describe('addDoc', function() { beforeEach(async function() { const doc = { _id: ObjectId(), name: 'other.txt' } this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.docs': doc }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.result = await this.subject.promises.addDoc( this.project._id, this.folder._id, doc ) }) it('adds the document in Mongo', function() { this.ProjectMock.verify() }) it('returns path info and the project', function() { expect(this.result).to.deep.equal({ result: { path: { mongo: 'rootFolder.0.folders.0', fileSystem: '/test-folder/other.txt' } }, project: this.project }) }) }) describe('addFile', function() { beforeEach(function() { this.newFile = { _id: ObjectId(), name: 'picture.jpg' } this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.fileRefs': this.newFile }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) }) describe('happy path', function() { beforeEach(async function() { this.result = await this.subject.promises.addFile( this.project._id, this.folder._id, this.newFile ) }) it('adds the file in Mongo', function() { this.ProjectMock.verify() }) it('returns path info and the project', function() { expect(this.result).to.deep.equal({ result: { path: { mongo: 'rootFolder.0.folders.0', fileSystem: '/test-folder/picture.jpg' } }, project: this.project }) }) }) describe('when entity limit is reached', function() { beforeEach(function() { this.savedMaxEntities = this.Settings.maxEntitiesPerProject this.Settings.maxEntitiesPerProject = 3 }) afterEach(function() { this.Settings.maxEntitiesPerProject = this.savedMaxEntities }) it('should throw an error', async function() { await expect( this.subject.promises.addFile( this.project._id, this.folder._id, this.newFile ) ).to.be.rejected }) }) }) describe('addFolder', function() { beforeEach(async function() { const folderName = 'New folder' this.FolderModel.withArgs({ name: folderName }).returns({ _id: ObjectId(), name: folderName }) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.folders': sinon.match({ name: folderName }) }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) await this.subject.promises.addFolder( this.project._id, this.folder._id, folderName ) }) it('adds the folder in Mongo', function() { this.ProjectMock.verify() }) }) describe('replaceFileWithNew', function() { beforeEach(async function() { const newFile = { _id: ObjectId(), name: 'some-other-file.png', linkedFileData: { some: 'data' }, hash: 'some-hash' } // Add a deleted file record this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id }, { $push: { deletedFiles: { _id: this.file._id, name: this.file.name, linkedFileData: this.file.linkedFileData, hash: this.file.hash, deletedAt: sinon.match.date } } } ) .chain('exec') .resolves() // Update the file in place this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $set: { 'rootFolder.0.fileRefs.0._id': newFile._id, 'rootFolder.0.fileRefs.0.created': sinon.match.date, 'rootFolder.0.fileRefs.0.linkedFileData': newFile.linkedFileData, 'rootFolder.0.fileRefs.0.hash': newFile.hash }, $inc: { version: 1, 'rootFolder.0.fileRefs.0.rev': 1 } } ) .chain('exec') .resolves(this.project) await this.subject.promises.replaceFileWithNew( this.project._id, this.file._id, newFile ) }) it('updates the database', function() { this.ProjectMock.verify() }) }) describe('mkdirp', function() { describe('when the path is just a slash', function() { beforeEach(async function() { this.result = await this.subject.promises.mkdirp(this.project._id, '/') }) it('should return the root folder', function() { expect(this.result.folder).to.deep.equal(this.rootFolder) }) it('should not report a parent folder', function() { expect(this.result.folder.parentFolder_id).not.to.exist }) it('should not return new folders', function() { expect(this.result.newFolders).to.have.length(0) }) }) describe('when the folder already exists', function() { beforeEach(async function() { this.result = await this.subject.promises.mkdirp( this.project._id, '/test-folder' ) }) it('should return the existing folder', function() { expect(this.result.folder).to.deep.equal(this.folder) }) it('should report the parent folder', function() { expect(this.result.folder.parentFolder_id).not.equal( this.rootFolder._id ) }) it('should not return new folders', function() { expect(this.result.newFolders).to.have.length(0) }) }) describe('when the path is a new folder at the top level', function() { beforeEach(async function() { this.newFolder = { _id: ObjectId(), name: 'new-folder' } this.FolderModel.returns(this.newFolder) this.exactCaseMatch = false this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders': this.newFolder }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.result = await this.subject.promises.mkdirp( this.project._id, '/new-folder/', { exactCaseMatch: this.exactCaseMatch } ) }) it('should update the database', function() { this.ProjectMock.verify() }) it('should make just one folder', function() { expect(this.result.newFolders).to.have.length(1) }) it('should return the new folder', function() { expect(this.result.folder.name).to.equal('new-folder') }) it('should return the parent folder', function() { expect(this.result.folder.parentFolder_id).to.equal(this.rootFolder._id) }) it('should pass the exactCaseMatch option to ProjectLocator', function() { expect( this.ProjectLocator.promises.findElementByPath ).to.have.been.calledWithMatch({ exactCaseMatch: this.exactCaseMatch }) }) }) describe('adding a subfolder', function() { beforeEach(async function() { this.newFolder = { _id: ObjectId(), name: 'new-folder' } this.FolderModel.returns(this.newFolder) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.folders': sinon.match({ name: 'new-folder' }) }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.result = await this.subject.promises.mkdirp( this.project._id, '/test-folder/new-folder' ) }) it('should update the database', function() { this.ProjectMock.verify() }) it('should create one folder', function() { expect(this.result.newFolders).to.have.length(1) }) it('should return the new folder', function() { expect(this.result.folder.name).to.equal('new-folder') }) it('should return the parent folder', function() { expect(this.result.folder.parentFolder_id).to.equal(this.folder._id) }) }) describe('when mutliple folders are missing', async function() { beforeEach(function() { this.folder1 = { _id: ObjectId(), name: 'folder1' } this.folder1Path = { fileSystem: '/test-folder/folder1', mongo: 'rootFolder.0.folders.0.folders.0' } this.folder2 = { _id: ObjectId(), name: 'folder2' } this.folder2Path = { fileSystem: '/test-folder/folder1/folder2', mongo: 'rootFolder.0.folders.0.folders.0.folders.0' } this.FolderModel.onFirstCall().returns(this.folder1) this.FolderModel.onSecondCall().returns(this.folder2) this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.folder1._id, type: 'folder' }) .resolves({ element: this.folder1, path: this.folder1Path }) this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.folder2._id, type: 'folder' }) .resolves({ element: this.folder2, path: this.folder2Path }) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.folders': sinon.match({ name: 'folder1' }) }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.folders.0.folders': sinon.match({ name: 'folder2' }) }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) }) ;[ { description: 'without a trailing slash', path: '/test-folder/folder1/folder2' }, { description: 'with a trailing slash', path: '/test-folder/folder1/folder2/' } ].forEach(({ description, path }) => { describe(description, function() { beforeEach(async function() { this.result = await this.subject.promises.mkdirp( this.project._id, path ) }) it('should update the database', function() { this.ProjectMock.verify() }) it('should add multiple folders', function() { const newFolders = this.result.newFolders expect(newFolders).to.have.length(2) expect(newFolders[0].name).to.equal('folder1') expect(newFolders[1].name).to.equal('folder2') }) it('should return the last folder', function() { expect(this.result.folder.name).to.equal('folder2') }) it('should return the parent folder', function() { expect(this.result.folder.parentFolder_id).to.equal( this.folder1._id ) }) }) }) }) }) describe('moveEntity', function() { describe('moving a doc into a different folder', function() { beforeEach(async function() { this.pathAfterMove = { fileSystem: '/somewhere/else.txt' } this.oldDocs = ['old-doc'] this.oldFiles = ['old-file'] this.newDocs = ['new-doc'] this.newFiles = ['new-file'] this.ProjectEntityHandler.promises.getAllEntitiesFromProject .onFirstCall() .resolves({ docs: this.oldDocs, files: this.oldFiles }) this.ProjectEntityHandler.promises.getAllEntitiesFromProject .onSecondCall() .resolves({ docs: this.newDocs, files: this.newFiles }) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.docs': this.doc }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $pull: { 'rootFolder.0.docs': { _id: this.doc._id } }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.result = await this.subject.promises.moveEntity( this.project._id, this.doc._id, this.folder._id, 'doc' ) }) it('should update the database', function() { this.ProjectMock.verify() }) it('should report what changed', function() { expect(this.result).to.deep.equal({ project: this.project, startPath: '/test-doc.txt', endPath: '/test-folder/test-doc.txt', rev: this.doc.rev, changes: { oldDocs: this.oldDocs, newDocs: this.newDocs, oldFiles: this.oldFiles, newFiles: this.newFiles, newProject: this.project } }) }) }) describe('when moving a folder inside itself', function() { it('throws an error', async function() { await expect( this.subject.promises.moveEntity( this.project._id, this.folder._id, this.folder._id, 'folder' ) ).to.be.rejectedWith(Errors.InvalidNameError) }) }) describe('when moving a folder to a subfolder of itself', function() { it('throws an error', async function() { await expect( this.subject.promises.moveEntity( this.project._id, this.folder._id, this.subfolder._id, 'folder' ) ).to.be.rejectedWith(Errors.InvalidNameError) }) }) }) describe('deleteEntity', function() { beforeEach(async function() { this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $pull: { 'rootFolder.0.docs': { _id: this.doc._id } }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) await this.subject.promises.deleteEntity( this.project._id, this.doc._id, 'doc' ) }) it('should update the database', function() { this.ProjectMock.verify() }) }) describe('renameEntity', function() { describe('happy path', function() { beforeEach(async function() { this.newName = 'new.tex' this.oldDocs = ['old-doc'] this.oldFiles = ['old-file'] this.newDocs = ['new-doc'] this.newFiles = ['new-file'] this.ProjectEntityHandler.promises.getAllEntitiesFromProject .onFirstCall() .resolves({ docs: this.oldDocs, files: this.oldFiles }) this.ProjectEntityHandler.promises.getAllEntitiesFromProject .onSecondCall() .resolves({ docs: this.newDocs, files: this.newFiles }) this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $set: { 'rootFolder.0.docs.0.name': this.newName }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) this.result = await this.subject.promises.renameEntity( this.project._id, this.doc._id, 'doc', this.newName ) }) it('should update the database', function() { this.ProjectMock.verify() }) it('returns info', function() { expect(this.result).to.deep.equal({ project: this.project, startPath: '/test-doc.txt', endPath: '/new.tex', rev: this.doc.rev, changes: { oldDocs: this.oldDocs, newDocs: this.newDocs, oldFiles: this.oldFiles, newFiles: this.newFiles, newProject: this.project } }) }) }) describe('name already exists', function() { it('should throw an error', async function() { await expect( this.subject.promises.renameEntity( this.project._id, this.doc._id, 'doc', this.folder.name ) ).to.be.rejectedWith(Errors.InvalidNameError) }) }) }) describe('_putElement', function() { describe('updating the project', function() { describe('when the parent folder is given', function() { beforeEach(function() { this.newFile = { _id: ObjectId(), name: 'new file.png' } this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.folders.0.fileRefs': this.newFile }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) }) it('should update the database', async function() { await this.subject.promises._putElement( this.project, this.folder._id, this.newFile, 'files' ) this.ProjectMock.verify() }) it('should add an s onto the type if not included', async function() { await this.subject.promises._putElement( this.project, this.folder._id, this.newFile, 'file' ) this.ProjectMock.verify() }) }) describe('error cases', function() { it('should throw an error if element is null', async function() { await expect( this.subject.promises._putElement( this.project, this.folder._id, null, 'file' ) ).to.be.rejected }) it('should error if the element has no _id', async function() { const file = { name: 'something' } await expect( this.subject.promises._putElement( this.project, this.folder._id, file, 'file' ) ).to.be.rejected }) it('should error if element name contains invalid characters', async function() { const file = { _id: ObjectId(), name: 'something*bad' } await expect( this.subject.promises._putElement( this.project, this.folder._id, file, 'file' ) ).to.be.rejected }) it('should error if element name is too long', async function() { const file = { _id: ObjectId(), name: 'long-'.repeat(1000) + 'something' } await expect( this.subject.promises._putElement( this.project, this.folder._id, file, 'file' ) ).to.be.rejectedWith(Errors.InvalidNameError) }) it('should error if the folder name is too long', async function() { const file = { _id: ObjectId(), name: 'something' } this.ProjectLocator.promises.findElement .withArgs({ project: this.project, element_id: this.folder._id, type: 'folder' }) .resolves({ element: this.folder, path: { fileSystem: 'subdir/'.repeat(1000) + 'foo' } }) await expect( this.subject.promises._putElement( this.project, this.folder._id, file, 'file' ) ).to.be.rejectedWith(Errors.InvalidNameError) }) ;['file', 'doc', 'folder'].forEach(entityType => { it(`should error if a ${entityType} already exists with the same name`, async function() { const file = { _id: ObjectId(), name: this[entityType].name } await expect( this.subject.promises._putElement( this.project, null, file, 'file' ) ).to.be.rejectedWith(Errors.InvalidNameError) }) }) }) }) describe('when the parent folder is not given', function() { it('should default to root folder insert', async function() { this.newFile = { _id: ObjectId(), name: 'new file.png' } this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $push: { 'rootFolder.0.fileRefs': this.newFile }, $inc: { version: 1 } } ) .chain('exec') .resolves(this.project) await this.subject.promises._putElement( this.project, this.rootFolder._id, this.newFile, 'file' ) }) }) }) describe('_insertDeletedDocReference', function() { beforeEach(async function() { this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id }, { $push: { deletedDocs: { _id: this.doc._id, name: this.doc.name, deletedAt: sinon.match.date } } } ) .chain('exec') .resolves() await this.subject.promises._insertDeletedDocReference( this.project._id, this.doc ) }) it('should update the database', function() { this.ProjectMock.verify() }) }) describe('_insertDeletedFileReference', function() { beforeEach(async function() { this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id }, { $push: { deletedFiles: { _id: this.file._id, name: this.file.name, linkedFileData: this.file.linkedFileData, hash: this.file.hash, deletedAt: sinon.match.date } } } ) .chain('exec') .resolves() await this.subject.promises._insertDeletedFileReference( this.project._id, this.file ) }) it('should update the database', function() { this.ProjectMock.verify() }) }) describe('createNewFolderStructure', function() { beforeEach(function() { this.mockRootFolder = 'MOCK_ROOT_FOLDER' this.docUploads = ['MOCK_DOC_UPLOAD'] this.fileUploads = ['MOCK_FILE_UPLOAD'] this.FolderStructureBuilder.buildFolderStructure .withArgs(this.docUploads, this.fileUploads) .returns(this.mockRootFolder) this.updateExpectation = this.ProjectMock.expects('updateOne') .withArgs( { _id: this.project._id, 'rootFolder.0.folders.0': { $exists: false }, 'rootFolder.0.docs.0': { $exists: false }, 'rootFolder.0.files.0': { $exists: false } }, { $set: { rootFolder: [this.mockRootFolder] }, $inc: { version: 1 } } ) .chain('exec') }) describe('happy path', function() { beforeEach(async function() { this.updateExpectation.resolves({ n: 1 }) await this.subject.promises.createNewFolderStructure( this.project._id, this.docUploads, this.fileUploads ) }) it('updates the database', function() { this.ProjectMock.verify() }) }) describe("when the update doesn't find a matching document", function() { beforeEach(async function() { this.updateExpectation.resolves({ n: 0 }) }) it('throws an error', async function() { await expect( this.subject.promises.createNewFolderStructure( this.project._id, this.docUploads, this.fileUploads ) ).to.be.rejected }) }) }) describe('replaceDocWithFile', function() { it('should simultaneously remove the doc and add the file', async function() { this.ProjectMock.expects('findOneAndUpdate') .withArgs( { _id: this.project._id }, { $pull: { 'rootFolder.0.docs': { _id: this.doc._id } }, $push: { 'rootFolder.0.fileRefs': this.file }, $inc: { version: 1 } }, { new: true } ) .chain('exec') .resolves(this.project) await this.subject.promises.replaceDocWithFile( this.project._id, this.doc._id, this.file ) this.ProjectMock.verify() }) }) })