| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598 |
- import sinon from 'sinon'
- import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
- import path from 'node:path'
- import { ObjectId } from 'mongodb-legacy'
- import Errors from '../../../app/js/Errors.js'
- const modulePath = path.join(
- import.meta.dirname,
- '../../../app/js/HttpController'
- )
- describe('HttpController', () => {
- beforeEach(async ctx => {
- const settings = {
- max_doc_length: 2 * 1024 * 1024,
- }
- ctx.DocArchiveManager = {
- unArchiveAllDocs: sinon.stub().returns(),
- }
- ctx.DocManager = {}
- vi.doMock('../../../app/js/DocManager', () => ({
- default: ctx.DocManager,
- }))
- vi.doMock('../../../app/js/DocArchiveManager', () => ({
- default: ctx.DocArchiveManager,
- }))
- vi.doMock('@overleaf/settings', () => ({
- default: settings,
- }))
- vi.doMock('../../../app/js/HealthChecker', () => ({
- default: {},
- }))
- vi.doMock('../../../app/js/Errors', () => ({
- default: Errors,
- }))
- ctx.HttpController = (await import(modulePath)).default
- ctx.res = {
- send: sinon.stub(),
- sendStatus: sinon.stub(),
- json: sinon.stub(),
- setHeader: sinon.stub(),
- }
- ctx.res.status = sinon.stub().returns(ctx.res)
- ctx.req = { query: {} }
- ctx.next = sinon.stub()
- ctx.projectId = 'mock-project-id'
- ctx.docId = 'mock-doc-id'
- ctx.doc = {
- _id: ctx.docId,
- lines: ['mock', 'lines', ' here', '', '', ' spaces '],
- version: 42,
- rev: 5,
- }
- ctx.deletedDoc = {
- deleted: true,
- _id: ctx.docId,
- lines: ['mock', 'lines', ' here', '', '', ' spaces '],
- version: 42,
- rev: 5,
- }
- })
- describe('getDoc', () => {
- describe('without deleted docs', () => {
- beforeEach(async ctx => {
- ctx.req.params = {
- project_id: ctx.projectId,
- doc_id: ctx.docId,
- }
- ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.doc)
- await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should get the document with the version (including deleted)', ctx => {
- ctx.DocManager.getFullDoc
- .calledWith(ctx.projectId, ctx.docId)
- .should.equal(true)
- })
- it('should return the doc as JSON', ctx => {
- ctx.res.json
- .calledWith({
- _id: ctx.docId,
- lines: ctx.doc.lines,
- rev: ctx.doc.rev,
- version: ctx.doc.version,
- })
- .should.equal(true)
- })
- })
- describe('which is deleted', () => {
- beforeEach(ctx => {
- ctx.req.params = {
- project_id: ctx.projectId,
- doc_id: ctx.docId,
- }
- ctx.DocManager.getFullDoc = sinon.stub().resolves(ctx.deletedDoc)
- })
- it('should get the doc from the doc manager', async ctx => {
- await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
- ctx.DocManager.getFullDoc
- .calledWith(ctx.projectId, ctx.docId)
- .should.equal(true)
- })
- it('should return 404 if the query string delete is not set ', async ctx => {
- await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
- ctx.res.sendStatus.calledWith(404).should.equal(true)
- })
- it('should return the doc as JSON if include_deleted is set to true', async ctx => {
- ctx.req.query.include_deleted = 'true'
- await ctx.HttpController.getDoc(ctx.req, ctx.res, ctx.next)
- ctx.res.json
- .calledWith({
- _id: ctx.docId,
- lines: ctx.doc.lines,
- rev: ctx.doc.rev,
- deleted: true,
- version: ctx.doc.version,
- })
- .should.equal(true)
- })
- })
- })
- describe('getRawDoc', () => {
- beforeEach(async ctx => {
- ctx.req.params = {
- project_id: ctx.projectId,
- doc_id: ctx.docId,
- }
- ctx.DocManager.getDocLines = sinon
- .stub()
- .resolves(ctx.doc.lines.join('\n'))
- await ctx.HttpController.getRawDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should get the document without the version', ctx => {
- ctx.DocManager.getDocLines
- .calledWith(ctx.projectId, ctx.docId)
- .should.equal(true)
- })
- it('should set the content type header', ctx => {
- ctx.res.setHeader
- .calledWith('content-type', 'text/plain')
- .should.equal(true)
- })
- it('should send the raw version of the doc', ctx => {
- assert.deepEqual(
- ctx.res.send.args[0][0],
- `${ctx.doc.lines[0]}\n${ctx.doc.lines[1]}\n${ctx.doc.lines[2]}\n${ctx.doc.lines[3]}\n${ctx.doc.lines[4]}\n${ctx.doc.lines[5]}`
- )
- })
- })
- describe('getAllDocs', () => {
- describe('normally', () => {
- beforeEach(async ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- ctx.docs = [
- {
- _id: new ObjectId(),
- lines: ['mock', 'lines', 'one'],
- rev: 2,
- },
- {
- _id: new ObjectId(),
- lines: ['mock', 'lines', 'two'],
- rev: 4,
- },
- ]
- ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
- await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
- })
- it('should get all the (non-deleted) docs', ctx => {
- ctx.DocManager.getAllNonDeletedDocs
- .calledWith(ctx.projectId, { lines: true, rev: true })
- .should.equal(true)
- })
- it('should return the doc as JSON', ctx => {
- ctx.res.json
- .calledWith([
- {
- _id: ctx.docs[0]._id.toString(),
- lines: ctx.docs[0].lines,
- rev: ctx.docs[0].rev,
- },
- {
- _id: ctx.docs[1]._id.toString(),
- lines: ctx.docs[1].lines,
- rev: ctx.docs[1].rev,
- },
- ])
- .should.equal(true)
- })
- })
- describe('with null lines', () => {
- beforeEach(async ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- ctx.docs = [
- {
- _id: new ObjectId(),
- lines: null,
- rev: 2,
- },
- {
- _id: new ObjectId(),
- lines: ['mock', 'lines', 'two'],
- rev: 4,
- },
- ]
- ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
- await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
- })
- it('should return the doc with fallback lines', ctx => {
- ctx.res.json
- .calledWith([
- {
- _id: ctx.docs[0]._id.toString(),
- lines: [],
- rev: ctx.docs[0].rev,
- },
- {
- _id: ctx.docs[1]._id.toString(),
- lines: ctx.docs[1].lines,
- rev: ctx.docs[1].rev,
- },
- ])
- .should.equal(true)
- })
- })
- describe('with a null doc', () => {
- beforeEach(async ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- ctx.docs = [
- {
- _id: new ObjectId(),
- lines: ['mock', 'lines', 'one'],
- rev: 2,
- },
- null,
- {
- _id: new ObjectId(),
- lines: ['mock', 'lines', 'two'],
- rev: 4,
- },
- ]
- ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
- await ctx.HttpController.getAllDocs(ctx.req, ctx.res, ctx.next)
- })
- it('should return the non null docs as JSON', ctx => {
- ctx.res.json
- .calledWith([
- {
- _id: ctx.docs[0]._id.toString(),
- lines: ctx.docs[0].lines,
- rev: ctx.docs[0].rev,
- },
- {
- _id: ctx.docs[2]._id.toString(),
- lines: ctx.docs[2].lines,
- rev: ctx.docs[2].rev,
- },
- ])
- .should.equal(true)
- })
- it('should log out an error', ctx => {
- ctx.logger.error
- .calledWith(
- {
- err: sinon.match.has('message', 'null doc'),
- projectId: ctx.projectId,
- },
- 'encountered null doc'
- )
- .should.equal(true)
- })
- })
- })
- describe('getAllRanges', () => {
- describe('normally', () => {
- beforeEach(async ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- ctx.docs = [
- {
- _id: new ObjectId(),
- ranges: { mock_ranges: 'one' },
- },
- {
- _id: new ObjectId(),
- ranges: { mock_ranges: 'two' },
- },
- ]
- ctx.DocManager.getAllNonDeletedDocs = sinon.stub().resolves(ctx.docs)
- await ctx.HttpController.getAllRanges(ctx.req, ctx.res, ctx.next)
- })
- it('should get all the (non-deleted) doc ranges', ctx => {
- ctx.DocManager.getAllNonDeletedDocs
- .calledWith(ctx.projectId, { ranges: true })
- .should.equal(true)
- })
- it('should return the doc as JSON', ctx => {
- ctx.res.json
- .calledWith([
- {
- _id: ctx.docs[0]._id.toString(),
- ranges: ctx.docs[0].ranges,
- },
- {
- _id: ctx.docs[1]._id.toString(),
- ranges: ctx.docs[1].ranges,
- },
- ])
- .should.equal(true)
- })
- })
- })
- describe('updateDoc', () => {
- beforeEach(ctx => {
- ctx.req.params = {
- project_id: ctx.projectId,
- doc_id: ctx.docId,
- }
- })
- describe('when the doc lines exist and were updated', () => {
- beforeEach(async ctx => {
- ctx.req.body = {
- lines: (ctx.lines = ['hello', 'world']),
- version: (ctx.version = 42),
- ranges: (ctx.ranges = { changes: 'mock' }),
- }
- ctx.rev = 5
- ctx.DocManager.updateDoc = sinon
- .stub()
- .resolves({ modified: true, rev: ctx.rev })
- await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should update the document', ctx => {
- ctx.DocManager.updateDoc
- .calledWith(
- ctx.projectId,
- ctx.docId,
- ctx.lines,
- ctx.version,
- ctx.ranges
- )
- .should.equal(true)
- })
- it('should return a modified status', ctx => {
- ctx.res.json
- .calledWith({ modified: true, rev: ctx.rev })
- .should.equal(true)
- })
- })
- describe('when the doc lines exist and were not updated', () => {
- beforeEach(async ctx => {
- ctx.req.body = {
- lines: (ctx.lines = ['hello', 'world']),
- version: (ctx.version = 42),
- ranges: {},
- }
- ctx.rev = 5
- ctx.DocManager.updateDoc = sinon
- .stub()
- .resolves({ modified: false, rev: ctx.rev })
- await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should return a modified status', ctx => {
- ctx.res.json
- .calledWith({ modified: false, rev: ctx.rev })
- .should.equal(true)
- })
- })
- describe('when the doc lines are not provided', () => {
- beforeEach(async ctx => {
- ctx.req.body = { version: 42, ranges: {} }
- ctx.DocManager.updateDoc = sinon
- .stub()
- .resolves({ modified: false, rev: 0 })
- await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should not update the document', ctx => {
- ctx.DocManager.updateDoc.called.should.equal(false)
- })
- it('should return a 400 (bad request) response', ctx => {
- ctx.res.sendStatus.calledWith(400).should.equal(true)
- })
- })
- describe('when the doc version are not provided', () => {
- beforeEach(async ctx => {
- ctx.req.body = { version: 42, lines: ['hello world'] }
- ctx.DocManager.updateDoc = sinon
- .stub()
- .resolves({ modified: false, rev: 0 })
- await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should not update the document', ctx => {
- ctx.DocManager.updateDoc.called.should.equal(false)
- })
- it('should return a 400 (bad request) response', ctx => {
- ctx.res.sendStatus.calledWith(400).should.equal(true)
- })
- })
- describe('when the doc ranges is not provided', () => {
- beforeEach(async ctx => {
- ctx.req.body = { lines: ['foo'], version: 42 }
- ctx.DocManager.updateDoc = sinon
- .stub()
- .resolves({ modified: false, rev: 0 })
- await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should not update the document', ctx => {
- ctx.DocManager.updateDoc.called.should.equal(false)
- })
- it('should return a 400 (bad request) response', ctx => {
- ctx.res.sendStatus.calledWith(400).should.equal(true)
- })
- })
- describe('when the doc body is too large', () => {
- beforeEach(async ctx => {
- ctx.req.body = {
- lines: (ctx.lines = Array(2049).fill('a'.repeat(1024))),
- version: (ctx.version = 42),
- ranges: (ctx.ranges = { changes: 'mock' }),
- }
- ctx.DocManager.updateDoc = sinon
- .stub()
- .resolves({ modified: false, rev: 0 })
- await ctx.HttpController.updateDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should not update the document', ctx => {
- ctx.DocManager.updateDoc.called.should.equal(false)
- })
- it('should return a 413 (too large) response', ctx => {
- sinon.assert.calledWith(ctx.res.status, 413)
- })
- it('should report that the document body is too large', ctx => {
- sinon.assert.calledWith(ctx.res.send, 'document body too large')
- })
- })
- })
- describe('patchDoc', () => {
- beforeEach(async ctx => {
- ctx.req.params = {
- project_id: ctx.projectId,
- doc_id: ctx.docId,
- }
- ctx.req.body = {
- deleted: true,
- deletedAt: '2026-06-15T00:00:00Z',
- name: 'foo.tex',
- }
- ctx.DocManager.patchDoc = sinon.stub().resolves()
- await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should delete the document', ctx => {
- expect(ctx.DocManager.patchDoc).to.have.been.calledWith(
- ctx.projectId,
- ctx.docId,
- {
- deleted: true,
- deletedAt: new Date('2026-06-15T00:00:00Z'),
- name: 'foo.tex',
- }
- )
- })
- it('should return a 204 (No Content)', ctx => {
- expect(ctx.res.sendStatus).to.have.been.calledWith(204)
- })
- describe('with an invalid payload', () => {
- beforeEach(async ctx => {
- ctx.req.body = { cannot: 'happen' }
- ctx.DocManager.patchDoc = sinon.stub().resolves()
- await ctx.HttpController.patchDoc(ctx.req, ctx.res, ctx.next)
- })
- it('should pass a validation error to next', ctx => {
- expect(ctx.next).to.have.been.calledOnce
- expect(ctx.next.firstCall.args[0].name).to.equal('InvalidRequestError')
- })
- it('should not patch the document', ctx => {
- expect(ctx.DocManager.patchDoc).not.to.have.been.called
- })
- })
- })
- describe('archiveAllDocs', () => {
- beforeEach(async ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- ctx.DocArchiveManager.archiveAllDocs = sinon.stub().resolves()
- await ctx.HttpController.archiveAllDocs(ctx.req, ctx.res, ctx.next)
- })
- it('should archive the project', ctx => {
- ctx.DocArchiveManager.archiveAllDocs
- .calledWith(ctx.projectId)
- .should.equal(true)
- })
- it('should return a 204 (No Content)', ctx => {
- ctx.res.sendStatus.calledWith(204).should.equal(true)
- })
- })
- describe('unArchiveAllDocs', () => {
- beforeEach(ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- })
- describe('on success', () => {
- beforeEach(async ctx => {
- await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next)
- })
- it('returns a 200', ctx => {
- expect(ctx.res.sendStatus).to.have.been.calledWith(200)
- })
- })
- describe("when the archived rev doesn't match", () => {
- beforeEach(async ctx => {
- ctx.DocArchiveManager.unArchiveAllDocs.rejects(
- new Errors.DocRevValueError('bad rev')
- )
- await ctx.HttpController.unArchiveAllDocs(ctx.req, ctx.res, ctx.next)
- })
- it('returns a 409', ctx => {
- expect(ctx.res.sendStatus).to.have.been.calledWith(409)
- })
- })
- })
- describe('destroyProject', () => {
- beforeEach(async ctx => {
- ctx.req.params = { project_id: ctx.projectId }
- ctx.DocArchiveManager.destroyProject = sinon.stub().resolves()
- await ctx.HttpController.destroyProject(ctx.req, ctx.res, ctx.next)
- })
- it('should destroy the docs', ctx => {
- sinon.assert.calledWith(
- ctx.DocArchiveManager.destroyProject,
- ctx.projectId
- )
- })
- it('should return 204', ctx => {
- sinon.assert.calledWith(ctx.res.sendStatus, 204)
- })
- })
- })
|