| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387 |
- import { vi, expect } from 'vitest'
- import sinon from 'sinon'
- import { RequestFailedError } from '@overleaf/fetch-utils'
- import Errors from '../../../../app/src/Features/Errors/Errors.js'
- import mongodb from 'mongodb-legacy'
- const { ObjectId } = mongodb
- const modulePath = '../../../../app/src/Features/History/HistoryController.mjs'
- describe('HistoryController', function () {
- beforeEach(async function (ctx) {
- ctx.callback = sinon.stub()
- ctx.user_id = 'user-id-123'
- ctx.project_id = '000000000000000012345678'
- ctx.blobHash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
- ctx.stream = sinon.stub()
- ctx.fetchResponse = {
- headers: {
- get: sinon.stub(),
- },
- }
- ctx.next = sinon.stub()
- ctx.SessionManager = {
- getLoggedInUserId: sinon.stub().returns(ctx.user_id),
- }
- ctx.Stream = {
- pipeline: sinon.stub().resolves(),
- }
- ctx.HistoryManager = {
- promises: {
- injectUserDetails: sinon.stub(),
- requestBlobWithProjectId: sinon.stub(),
- },
- }
- ctx.ProjectEntityUpdateHandler = {
- promises: {
- resyncProjectHistory: sinon.stub().resolves(),
- },
- }
- ctx.fetchJson = sinon.stub()
- ctx.fetchStream = sinon.stub().resolves(ctx.stream)
- ctx.fetchStreamWithResponse = sinon
- .stub()
- .resolves({ stream: ctx.stream, response: ctx.fetchResponse })
- ctx.fetchNothing = sinon.stub().resolves()
- vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
- vi.importActual('../../../../app/src/Features/Errors/Errors.js')
- )
- vi.doMock('stream/promises', () => ctx.Stream)
- vi.doMock('@overleaf/settings', () => ({
- default: (ctx.settings = {}),
- }))
- vi.doMock('@overleaf/fetch-utils', () => ({
- fetchJson: ctx.fetchJson,
- fetchStream: ctx.fetchStream,
- fetchStreamWithResponse: ctx.fetchStreamWithResponse,
- fetchNothing: ctx.fetchNothing,
- }))
- vi.doMock('@overleaf/Metrics', () => ({
- default: {},
- }))
- vi.doMock('../../../../app/src/infrastructure/mongodb.js', () => ({
- default: { ObjectId },
- }))
- vi.doMock(
- '../../../../app/src/Features/Authentication/SessionManager.js',
- () => ({
- default: ctx.SessionManager,
- })
- )
- vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({
- default: ctx.HistoryManager,
- }))
- vi.doMock(
- '../../../../app/src/Features/Project/ProjectDetailsHandler.js',
- () => ({
- default: (ctx.ProjectDetailsHandler = {}),
- })
- )
- vi.doMock(
- '../../../../app/src/Features/Project/ProjectEntityUpdateHandler.js',
- () => ({
- default: ctx.ProjectEntityUpdateHandler,
- })
- )
- vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
- default: (ctx.UserGetter = {}),
- }))
- vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
- default: (ctx.ProjectGetter = {}),
- }))
- vi.doMock(
- '../../../../app/src/Features/History/RestoreManager.mjs',
- () => ({
- default: (ctx.RestoreManager = {}),
- })
- )
- vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({
- default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)),
- }))
- ctx.HistoryController = (await import(modulePath)).default
- ctx.settings.apis = {
- project_history: {
- url: 'http://project_history.example.com',
- },
- }
- })
- describe('proxyToHistoryApi', function () {
- beforeEach(async function (ctx) {
- ctx.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
- ctx.res = {
- set: sinon.stub(),
- }
- ctx.contentType = 'application/json'
- ctx.contentLength = 212
- ctx.fetchResponse.headers.get
- .withArgs('Content-Type')
- .returns(ctx.contentType)
- ctx.fetchResponse.headers.get
- .withArgs('Content-Length')
- .returns(ctx.contentLength)
- await ctx.HistoryController.proxyToHistoryApi(ctx.req, ctx.res, ctx.next)
- })
- it('should get the user id', function (ctx) {
- ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
- ctx.req.session
- )
- })
- it('should call the project history api', function (ctx) {
- ctx.fetchStreamWithResponse.should.have.been.calledWith(
- `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
- {
- method: ctx.req.method,
- headers: {
- 'X-User-Id': ctx.user_id,
- },
- }
- )
- })
- it('should pipe the response to the client', function (ctx) {
- expect(ctx.Stream.pipeline).to.have.been.calledWith(ctx.stream, ctx.res)
- })
- it('should propagate the appropriate headers', function (ctx) {
- expect(ctx.res.set).to.have.been.calledWith(
- 'Content-Type',
- ctx.contentType
- )
- expect(ctx.res.set).to.have.been.calledWith(
- 'Content-Length',
- ctx.contentLength
- )
- })
- })
- describe('proxyToHistoryApiAndInjectUserDetails', function () {
- beforeEach(async function (ctx) {
- ctx.req = { url: '/mock/url', method: 'POST' }
- ctx.res = { json: sinon.stub() }
- ctx.data = 'mock-data'
- ctx.dataWithUsers = 'mock-injected-data'
- ctx.fetchJson.resolves(ctx.data)
- ctx.HistoryManager.promises.injectUserDetails.resolves(ctx.dataWithUsers)
- await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
- ctx.req,
- ctx.res,
- ctx.next
- )
- })
- it('should get the user id', function (ctx) {
- ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
- ctx.req.session
- )
- })
- it('should call the project history api', function (ctx) {
- ctx.fetchJson.should.have.been.calledWith(
- `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
- {
- method: ctx.req.method,
- headers: {
- 'X-User-Id': ctx.user_id,
- },
- }
- )
- })
- it('should inject the user data', function (ctx) {
- ctx.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
- ctx.data
- )
- })
- it('should return the data with users to the client', function (ctx) {
- ctx.res.json.should.have.been.calledWith(ctx.dataWithUsers)
- })
- })
- describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
- beforeEach(async function (ctx) {
- ctx.url = '/mock/url'
- ctx.req = { url: ctx.url, method: 'POST' }
- ctx.res = { json: sinon.stub() }
- ctx.err = new RequestFailedError(ctx.url, {}, { status: 500 })
- ctx.fetchJson.rejects(ctx.err)
- await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
- ctx.req,
- ctx.res,
- ctx.next
- )
- })
- it('should not inject the user data', function (ctx) {
- ctx.HistoryManager.promises.injectUserDetails.should.not.have.been.called
- })
- it('should not return the data with users to the client', function (ctx) {
- ctx.res.json.should.not.have.been.called
- })
- it('should throw an error', function (ctx) {
- ctx.next.should.have.been.calledWith(ctx.err)
- })
- })
- describe('resyncProjectHistory', function () {
- describe('for a project without project-history enabled', function () {
- beforeEach(async function (ctx) {
- ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
- ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
- ctx.error = new Errors.ProjectHistoryDisabledError()
- ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
- ctx.error
- )
- await ctx.HistoryController.resyncProjectHistory(
- ctx.req,
- ctx.res,
- ctx.next
- )
- })
- it('response with a 404', function (ctx) {
- ctx.res.sendStatus.should.have.been.calledWith(404)
- })
- })
- describe('for a project with project-history enabled', function () {
- beforeEach(async function (ctx) {
- ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
- ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
- await ctx.HistoryController.resyncProjectHistory(
- ctx.req,
- ctx.res,
- ctx.next
- )
- })
- it('sets an extended response timeout', function (ctx) {
- ctx.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000)
- })
- it('resyncs the project', function (ctx) {
- ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
- ctx.project_id
- )
- })
- it('responds with a 204', function (ctx) {
- ctx.res.sendStatus.should.have.been.calledWith(204)
- })
- })
- })
- describe('requestBlob', function () {
- describe('With Range header', function () {
- beforeEach(async function (ctx) {
- ctx.req = {
- params: {
- project_id: ctx.project_id,
- hash: ctx.blobHash,
- },
- query: {},
- body: {},
- get: sinon.stub(),
- }
- ctx.req.get.withArgs('Range').returns('bytes=0-42')
- ctx.res = { setHeader: sinon.stub(), status: sinon.stub() }
- ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
- stream: null,
- contentLength: '43',
- contentRange: 'bytes 0-42/100',
- })
- await ctx.HistoryController.getBlob(ctx.req, ctx.res, ctx.next)
- })
- it('should forward the range request', function (ctx) {
- ctx.HistoryManager.promises.requestBlobWithProjectId.should.have.been.calledWith(
- sinon.match(val => val.toString() === ctx.project_id),
- ctx.blobHash,
- 'GET',
- 'bytes=0-42'
- )
- })
- it('should forward the Content-Range header', function (ctx) {
- ctx.res.setHeader.should.have.been.calledWith(
- 'Content-Range',
- 'bytes 0-42/100'
- )
- })
- it('should forward the Content-Length header', function (ctx) {
- ctx.res.setHeader.should.have.been.calledWith('Content-Length', '43')
- })
- it('should have status 206', function (ctx) {
- ctx.res.status.should.have.been.calledWith(206)
- })
- })
- describe('Without Range header', function () {
- beforeEach(async function (ctx) {
- ctx.req = {
- params: {
- project_id: ctx.project_id,
- hash: ctx.blobHash,
- },
- query: {},
- body: {},
- get: sinon.stub(),
- }
- ctx.req.get.withArgs('Range').returns(null)
- ctx.res = { setHeader: sinon.stub(), status: sinon.stub() }
- ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
- stream: null,
- contentLength: '100',
- range: null,
- })
- await ctx.HistoryController.getBlob(ctx.req, ctx.res, ctx.next)
- })
- it('should not have a Content-Range header', function (ctx) {
- expect(ctx.res.setHeader).to.not.have.been.calledWith(
- 'Content-Range',
- sinon.match.string
- )
- })
- it('should forward the Content-Length header', function (ctx) {
- ctx.res.setHeader.should.have.been.calledWith('Content-Length', '100')
- })
- it('should not have status 206', function (ctx) {
- ctx.res.status.should.not.have.been.calledWith(206)
- })
- })
- })
- })
|