const sinon = require('sinon') const modulePath = '../../../../app/js/HttpController.js' const SandboxedModule = require('sandboxed-module') const Errors = require('../../../../app/js/Errors.js') describe('HttpController', function () { beforeEach(function () { this.HttpController = SandboxedModule.require(modulePath, { requires: { './DocumentManager': (this.DocumentManager = {}), './HistoryManager': (this.HistoryManager = { flushProjectChangesAsync: sinon.stub(), }), './ProjectHistoryRedisManager': (this.ProjectHistoryRedisManager = {}), './ProjectManager': (this.ProjectManager = {}), './ProjectFlusher': { flushAllProjects() {} }, './DeleteQueueManager': (this.DeleteQueueManager = {}), './RedisManager': (this.RedisManager = {}), './Metrics': (this.Metrics = {}), './Errors': Errors, '@overleaf/settings': { max_doc_length: 2 * 1024 * 1024 }, }, }) this.Metrics.Timer = class Timer {} this.Metrics.Timer.prototype.done = sinon.stub() this.project_id = 'project-id-123' this.doc_id = 'doc-id-123' this.source = 'editor' this.next = sinon.stub() this.res = { send: sinon.stub(), sendStatus: sinon.stub(), json: sinon.stub(), } }) describe('getDoc', function () { beforeEach(function () { this.lines = ['one', 'two', 'three'] this.ops = ['mock-op-1', 'mock-op-2'] this.version = 42 this.fromVersion = 42 this.ranges = { changes: 'mock', comments: 'mock' } this.pathname = '/a/b/c' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, }, query: {}, body: {}, } }) describe('when the document exists and no recent ops are requested', function () { beforeEach(function () { this.DocumentManager.getDocAndRecentOpsWithLock = sinon .stub() .callsArgWith( 3, null, this.lines, this.version, [], this.ranges, this.pathname ) this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the doc', function () { this.DocumentManager.getDocAndRecentOpsWithLock .calledWith(this.project_id, this.doc_id, -1) .should.equal(true) }) it('should return the doc as JSON', function () { this.res.json .calledWith({ id: this.doc_id, lines: this.lines, version: this.version, ops: [], ranges: this.ranges, pathname: this.pathname, }) .should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id }, 'getting doc via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when recent ops are requested', function () { beforeEach(function () { this.DocumentManager.getDocAndRecentOpsWithLock = sinon .stub() .callsArgWith( 3, null, this.lines, this.version, this.ops, this.ranges, this.pathname ) this.req.query = { fromVersion: `${this.fromVersion}` } this.HttpController.getDoc(this.req, this.res, this.next) }) it('should get the doc', function () { this.DocumentManager.getDocAndRecentOpsWithLock .calledWith(this.project_id, this.doc_id, this.fromVersion) .should.equal(true) }) it('should return the doc as JSON', function () { this.res.json .calledWith({ id: this.doc_id, lines: this.lines, version: this.version, ops: this.ops, ranges: this.ranges, pathname: this.pathname, }) .should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id }, 'getting doc via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when the document does not exist', function () { beforeEach(function () { this.DocumentManager.getDocAndRecentOpsWithLock = sinon .stub() .callsArgWith(3, null, null, null) this.HttpController.getDoc(this.req, this.res, this.next) }) it('should call next with NotFoundError', function () { this.next .calledWith(sinon.match.instanceOf(Errors.NotFoundError)) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.getDocAndRecentOpsWithLock = sinon .stub() .callsArgWith(3, new Error('oops'), null, null) this.HttpController.getDoc(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('setDoc', function () { beforeEach(function () { this.lines = ['one', 'two', 'three'] this.source = 'dropbox' this.user_id = 'user-id-123' this.req = { headers: {}, params: { project_id: this.project_id, doc_id: this.doc_id, }, query: {}, body: { lines: this.lines, source: this.source, user_id: this.user_id, undoing: (this.undoing = true), }, } }) describe('successfully', function () { beforeEach(function () { this.DocumentManager.setDocWithLock = sinon .stub() .callsArgWith(6, null, { rev: '123' }) this.HttpController.setDoc(this.req, this.res, this.next) }) it('should set the doc', function () { this.DocumentManager.setDocWithLock .calledWith( this.project_id, this.doc_id, this.lines, this.source, this.user_id, this.undoing ) .should.equal(true) }) it('should return a json response with the document rev from web', function () { this.res.json.calledWithMatch({ rev: '123' }).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id, lines: this.lines, source: this.source, userId: this.user_id, undoing: this.undoing, }, 'setting doc via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.setDocWithLock = sinon .stub() .callsArgWith(6, new Error('oops')) this.HttpController.setDoc(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) describe('when the payload is too large', function () { beforeEach(function () { const lines = [] for (let _ = 0; _ <= 200000; _++) { lines.push('test test test') } this.req.body.lines = lines this.DocumentManager.setDocWithLock = sinon.stub().callsArgWith(6) this.HttpController.setDoc(this.req, this.res, this.next) }) it('should send back a 406 response', function () { this.res.sendStatus.calledWith(406).should.equal(true) }) it('should not call setDocWithLock', function () { this.DocumentManager.setDocWithLock.callCount.should.equal(0) }) }) }) describe('flushProject', function () { beforeEach(function () { this.req = { params: { project_id: this.project_id, }, query: {}, body: {}, } }) describe('successfully', function () { beforeEach(function () { this.ProjectManager.flushProjectWithLocks = sinon.stub().callsArgWith(1) this.HttpController.flushProject(this.req, this.res, this.next) }) it('should flush the project', function () { this.ProjectManager.flushProjectWithLocks .calledWith(this.project_id) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id }, 'flushing project via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.ProjectManager.flushProjectWithLocks = sinon .stub() .callsArgWith(1, new Error('oops')) this.HttpController.flushProject(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('flushDocIfLoaded', function () { beforeEach(function () { this.lines = ['one', 'two', 'three'] this.version = 42 this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, }, query: {}, body: {}, } }) describe('successfully', function () { beforeEach(function () { this.DocumentManager.flushDocIfLoadedWithLock = sinon .stub() .callsArgWith(2) this.HttpController.flushDocIfLoaded(this.req, this.res, this.next) }) it('should flush the doc', function () { this.DocumentManager.flushDocIfLoadedWithLock .calledWith(this.project_id, this.doc_id) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id }, 'flushing doc via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.flushDocIfLoadedWithLock = sinon .stub() .callsArgWith(2, new Error('oops')) this.HttpController.flushDocIfLoaded(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('deleteDoc', function () { beforeEach(function () { this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, }, query: {}, body: {}, } }) describe('successfully', function () { beforeEach(function () { this.DocumentManager.flushAndDeleteDocWithLock = sinon .stub() .callsArgWith(3) this.HttpController.deleteDoc(this.req, this.res, this.next) }) it('should flush and delete the doc', function () { this.DocumentManager.flushAndDeleteDocWithLock .calledWith(this.project_id, this.doc_id, { ignoreFlushErrors: false, }) .should.equal(true) }) it('should flush project history', function () { this.HistoryManager.flushProjectChangesAsync .calledWithExactly(this.project_id) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { docId: this.doc_id, projectId: this.project_id }, 'deleting doc via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('ignoring errors', function () { beforeEach(function () { this.req.query.ignore_flush_errors = 'true' this.DocumentManager.flushAndDeleteDocWithLock = sinon.stub().yields() this.HttpController.deleteDoc(this.req, this.res, this.next) }) it('should delete the doc', function () { this.DocumentManager.flushAndDeleteDocWithLock .calledWith(this.project_id, this.doc_id, { ignoreFlushErrors: true }) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.flushAndDeleteDocWithLock = sinon .stub() .callsArgWith(3, new Error('oops')) this.HttpController.deleteDoc(this.req, this.res, this.next) }) it('should flush project history', function () { this.HistoryManager.flushProjectChangesAsync .calledWithExactly(this.project_id) .should.equal(true) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('deleteProject', function () { beforeEach(function () { this.req = { params: { project_id: this.project_id, }, query: {}, body: {}, } }) describe('successfully', function () { beforeEach(function () { this.ProjectManager.flushAndDeleteProjectWithLocks = sinon .stub() .callsArgWith(2) this.HttpController.deleteProject(this.req, this.res, this.next) }) it('should delete the project', function () { this.ProjectManager.flushAndDeleteProjectWithLocks .calledWith(this.project_id) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id }, 'deleting project via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('with the background=true option from realtime', function () { beforeEach(function () { this.ProjectManager.queueFlushAndDeleteProject = sinon .stub() .callsArgWith(1) this.req.query = { background: true, shutdown: true } this.HttpController.deleteProject(this.req, this.res, this.next) }) it('should queue the flush and delete', function () { this.ProjectManager.queueFlushAndDeleteProject .calledWith(this.project_id) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.ProjectManager.flushAndDeleteProjectWithLocks = sinon .stub() .callsArgWith(2, new Error('oops')) this.HttpController.deleteProject(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('acceptChanges', function () { beforeEach(function () { this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, change_id: (this.change_id = 'mock-change-od-1'), }, query: {}, body: {}, } }) describe('successfully with a single change', function () { beforeEach(function () { this.DocumentManager.acceptChangesWithLock = sinon .stub() .callsArgWith(3) this.HttpController.acceptChanges(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.acceptChangesWithLock .calledWith(this.project_id, this.doc_id, [this.change_id]) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, docId: this.doc_id }, 'accepting 1 changes via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('succesfully with with multiple changes', function () { beforeEach(function () { this.change_ids = [ 'mock-change-od-1', 'mock-change-od-2', 'mock-change-od-3', 'mock-change-od-4', ] this.req.body = { change_ids: this.change_ids } this.DocumentManager.acceptChangesWithLock = sinon .stub() .callsArgWith(3) this.HttpController.acceptChanges(this.req, this.res, this.next) }) it('should accept the changes in the body payload', function () { this.DocumentManager.acceptChangesWithLock .calledWith(this.project_id, this.doc_id, this.change_ids) .should.equal(true) }) it('should log the request with the correct number of changes', function () { this.logger.debug .calledWith( { projectId: this.project_id, docId: this.doc_id }, `accepting ${this.change_ids.length} changes via http` ) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.acceptChangesWithLock = sinon .stub() .callsArgWith(3, new Error('oops')) this.HttpController.acceptChanges(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('resolveComment', function () { beforeEach(function () { this.user_id = 'user-id-123' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: (this.comment_id = 'mock-comment-id'), }, query: {}, body: { user_id: this.user_id, }, } this.resolved = true }) describe('successfully', function () { beforeEach(function (done) { this.DocumentManager.updateCommentStateWithLock = sinon .stub() .callsArgWith(5) this.ProjectHistoryRedisManager.queueOps = sinon.stub() this.res.sendStatus.callsFake(() => done()) this.HttpController.resolveComment(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.updateCommentStateWithLock .calledWith( this.project_id, this.doc_id, this.comment_id, this.user_id, this.resolved ) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, docId: this.doc_id, commentId: this.comment_id, }, 'resolving comment via http' ) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.updateCommentStateWithLock = sinon .stub() .callsArgWith(5, new Error('oops')) this.HttpController.resolveComment(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('reopenComment', function () { beforeEach(function () { this.user_id = 'user-id-123' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: (this.comment_id = 'mock-comment-id'), }, query: {}, body: { user_id: this.user_id, }, } this.resolved = false }) describe('successfully', function () { beforeEach(function () { this.DocumentManager.updateCommentStateWithLock = sinon .stub() .callsArgWith(5) this.ProjectHistoryRedisManager.queueOps = sinon.stub() this.HttpController.reopenComment(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.updateCommentStateWithLock .calledWith( this.project_id, this.doc_id, this.comment_id, this.user_id, this.resolved ) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, docId: this.doc_id, commentId: this.comment_id, }, 'reopening comment via http' ) .should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.updateCommentStateWithLock = sinon .stub() .callsArgWith(5, new Error('oops')) this.HttpController.reopenComment(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('deleteComment', function () { beforeEach(function () { this.user_id = 'user-id-123' this.req = { params: { project_id: this.project_id, doc_id: this.doc_id, comment_id: (this.comment_id = 'mock-comment-id'), }, query: {}, body: { user_id: this.user_id, }, } }) describe('successfully', function () { beforeEach(function () { this.DocumentManager.deleteCommentWithLock = sinon .stub() .callsArgWith(4) this.ProjectHistoryRedisManager.queueOps = sinon.stub() this.HttpController.deleteComment(this.req, this.res, this.next) }) it('should accept the change', function () { this.DocumentManager.deleteCommentWithLock .calledWith( this.project_id, this.doc_id, this.comment_id, this.user_id ) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, docId: this.doc_id, commentId: this.comment_id, }, 'deleting comment via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.DocumentManager.deleteCommentWithLock = sinon .stub() .callsArgWith(4, new Error('oops')) this.HttpController.deleteComment(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('getProjectDocsAndFlushIfOld', function () { beforeEach(function () { this.state = '01234567890abcdef' this.docs = [ { _id: '1234', lines: 'hello', v: 23 }, { _id: '4567', lines: 'world', v: 45 }, ] this.req = { params: { project_id: this.project_id, }, query: { state: this.state, }, body: {}, } }) describe('successfully', function () { beforeEach(function () { this.ProjectManager.getProjectDocsAndFlushIfOld = sinon .stub() .callsArgWith(3, null, this.docs) this.HttpController.getProjectDocsAndFlushIfOld( this.req, this.res, this.next ) }) it('should get docs from the project manager', function () { this.ProjectManager.getProjectDocsAndFlushIfOld .calledWith(this.project_id, this.state, {}) .should.equal(true) }) it('should return a successful response', function () { this.res.send.calledWith(this.docs).should.equal(true) }) it('should log the request', function () { this.logger.debug .calledWith( { projectId: this.project_id, exclude: [] }, 'getting docs via http' ) .should.equal(true) }) it('should log the response', function () { this.logger.debug .calledWith( { projectId: this.project_id, result: ['1234:23', '4567:45'] }, 'got docs via http' ) .should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when there is a conflict', function () { beforeEach(function () { this.ProjectManager.getProjectDocsAndFlushIfOld = sinon .stub() .callsArgWith( 3, new Errors.ProjectStateChangedError('project state changed') ) this.HttpController.getProjectDocsAndFlushIfOld( this.req, this.res, this.next ) }) it('should return an HTTP 409 Conflict response', function () { this.res.sendStatus.calledWith(409).should.equal(true) }) }) describe('when an error occurs', function () { beforeEach(function () { this.ProjectManager.getProjectDocsAndFlushIfOld = sinon .stub() .callsArgWith(3, new Error('oops')) this.HttpController.getProjectDocsAndFlushIfOld( this.req, this.res, this.next ) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('updateProject', function () { beforeEach(function () { this.projectHistoryId = 'history-id-123' this.userId = 'user-id-123' this.updates = [ { type: 'rename-doc', id: 1, pathname: 'thesis.tex', newPathname: 'book.tex', }, { type: 'add-doc', id: 2, pathname: 'article.tex', docLines: 'hello' }, { type: 'rename-file', id: 3, pathname: 'apple.png', newPathname: 'banana.png', }, { type: 'add-file', id: 4, url: 'filestore.example.com/4' }, ] this.version = 1234567 this.req = { query: {}, body: { projectHistoryId: this.projectHistoryId, userId: this.userId, updates: this.updates, version: this.version, source: this.source, }, params: { project_id: this.project_id, }, } }) describe('successfully', function () { beforeEach(function () { this.ProjectManager.updateProjectWithLocks = sinon.stub().yields() this.HttpController.updateProject(this.req, this.res, this.next) }) it('should accept the change', function () { this.ProjectManager.updateProjectWithLocks .calledWith( this.project_id, this.projectHistoryId, this.userId, this.updates, this.version, this.source ) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.calledWith(204).should.equal(true) }) it('should time the request', function () { this.Metrics.Timer.prototype.done.called.should.equal(true) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.ProjectManager.updateProjectWithLocks = sinon .stub() .yields(new Error('oops')) this.HttpController.updateProject(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) describe('resyncProjectHistory', function () { beforeEach(function () { this.projectHistoryId = 'history-id-123' this.docs = sinon.stub() this.files = sinon.stub() this.fileUpdates = sinon.stub() this.req = { query: {}, body: { projectHistoryId: this.projectHistoryId, docs: this.docs, files: this.files, }, params: { project_id: this.project_id, }, } }) describe('successfully', function () { beforeEach(function () { this.HistoryManager.resyncProjectHistory = sinon.stub().callsArgWith(5) this.HttpController.resyncProjectHistory(this.req, this.res, this.next) }) it('should accept the change', function () { this.HistoryManager.resyncProjectHistory .calledWith( this.project_id, this.projectHistoryId, this.docs, this.files, {} ) .should.equal(true) }) it('should return a successful No Content response', function () { this.res.sendStatus.should.have.been.calledWith(204) }) }) describe('when an errors occurs', function () { beforeEach(function () { this.HistoryManager.resyncProjectHistory = sinon .stub() .callsArgWith(5, new Error('oops')) this.HttpController.resyncProjectHistory(this.req, this.res, this.next) }) it('should call next with the error', function () { this.next.calledWith(sinon.match.instanceOf(Error)).should.equal(true) }) }) }) })