HistoryController.test.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. import { vi, expect } from 'vitest'
  2. import sinon from 'sinon'
  3. import { RequestFailedError } from '@overleaf/fetch-utils'
  4. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  5. import mongodb from 'mongodb-legacy'
  6. const { ObjectId } = mongodb
  7. const modulePath = '../../../../app/src/Features/History/HistoryController.mjs'
  8. describe('HistoryController', function () {
  9. beforeEach(async function (ctx) {
  10. ctx.callback = sinon.stub()
  11. ctx.user_id = 'user-id-123'
  12. ctx.project_id = 'mock-project-id'
  13. ctx.stream = sinon.stub()
  14. ctx.fetchResponse = {
  15. headers: {
  16. get: sinon.stub(),
  17. },
  18. }
  19. ctx.next = sinon.stub()
  20. ctx.SessionManager = {
  21. getLoggedInUserId: sinon.stub().returns(ctx.user_id),
  22. }
  23. ctx.Stream = {
  24. pipeline: sinon.stub().resolves(),
  25. }
  26. ctx.HistoryManager = {
  27. promises: {
  28. injectUserDetails: sinon.stub(),
  29. requestBlobWithProjectId: sinon.stub(),
  30. },
  31. }
  32. ctx.ProjectEntityUpdateHandler = {
  33. promises: {
  34. resyncProjectHistory: sinon.stub().resolves(),
  35. },
  36. }
  37. ctx.fetchJson = sinon.stub()
  38. ctx.fetchStream = sinon.stub().resolves(ctx.stream)
  39. ctx.fetchStreamWithResponse = sinon
  40. .stub()
  41. .resolves({ stream: ctx.stream, response: ctx.fetchResponse })
  42. ctx.fetchNothing = sinon.stub().resolves()
  43. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  44. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  45. )
  46. vi.doMock('stream/promises', () => ctx.Stream)
  47. vi.doMock('@overleaf/settings', () => ({
  48. default: (ctx.settings = {}),
  49. }))
  50. vi.doMock('@overleaf/fetch-utils', () => ({
  51. fetchJson: ctx.fetchJson,
  52. fetchStream: ctx.fetchStream,
  53. fetchStreamWithResponse: ctx.fetchStreamWithResponse,
  54. fetchNothing: ctx.fetchNothing,
  55. }))
  56. vi.doMock('@overleaf/Metrics', () => ({
  57. default: {},
  58. }))
  59. vi.doMock('../../../../app/src/infrastructure/mongodb.js', () => ({
  60. default: { ObjectId },
  61. }))
  62. vi.doMock(
  63. '../../../../app/src/Features/Authentication/SessionManager.js',
  64. () => ({
  65. default: ctx.SessionManager,
  66. })
  67. )
  68. vi.doMock('../../../../app/src/Features/History/HistoryManager.js', () => ({
  69. default: ctx.HistoryManager,
  70. }))
  71. vi.doMock(
  72. '../../../../app/src/Features/Project/ProjectDetailsHandler.js',
  73. () => ({
  74. default: (ctx.ProjectDetailsHandler = {}),
  75. })
  76. )
  77. vi.doMock(
  78. '../../../../app/src/Features/Project/ProjectEntityUpdateHandler.js',
  79. () => ({
  80. default: ctx.ProjectEntityUpdateHandler,
  81. })
  82. )
  83. vi.doMock('../../../../app/src/Features/User/UserGetter.js', () => ({
  84. default: (ctx.UserGetter = {}),
  85. }))
  86. vi.doMock('../../../../app/src/Features/Project/ProjectGetter.js', () => ({
  87. default: (ctx.ProjectGetter = {}),
  88. }))
  89. vi.doMock(
  90. '../../../../app/src/Features/History/RestoreManager.mjs',
  91. () => ({
  92. default: (ctx.RestoreManager = {}),
  93. })
  94. )
  95. vi.doMock('../../../../app/src/infrastructure/Features.js', () => ({
  96. default: (ctx.Features = sinon.stub().withArgs('saas').returns(true)),
  97. }))
  98. ctx.HistoryController = (await import(modulePath)).default
  99. ctx.settings.apis = {
  100. project_history: {
  101. url: 'http://project_history.example.com',
  102. },
  103. }
  104. })
  105. describe('proxyToHistoryApi', function () {
  106. beforeEach(async function (ctx) {
  107. ctx.req = { url: '/mock/url', method: 'POST', session: sinon.stub() }
  108. ctx.res = {
  109. set: sinon.stub(),
  110. }
  111. ctx.contentType = 'application/json'
  112. ctx.contentLength = 212
  113. ctx.fetchResponse.headers.get
  114. .withArgs('Content-Type')
  115. .returns(ctx.contentType)
  116. ctx.fetchResponse.headers.get
  117. .withArgs('Content-Length')
  118. .returns(ctx.contentLength)
  119. await ctx.HistoryController.proxyToHistoryApi(ctx.req, ctx.res, ctx.next)
  120. })
  121. it('should get the user id', function (ctx) {
  122. ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
  123. ctx.req.session
  124. )
  125. })
  126. it('should call the project history api', function (ctx) {
  127. ctx.fetchStreamWithResponse.should.have.been.calledWith(
  128. `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
  129. {
  130. method: ctx.req.method,
  131. headers: {
  132. 'X-User-Id': ctx.user_id,
  133. },
  134. }
  135. )
  136. })
  137. it('should pipe the response to the client', function (ctx) {
  138. expect(ctx.Stream.pipeline).to.have.been.calledWith(ctx.stream, ctx.res)
  139. })
  140. it('should propagate the appropriate headers', function (ctx) {
  141. expect(ctx.res.set).to.have.been.calledWith(
  142. 'Content-Type',
  143. ctx.contentType
  144. )
  145. expect(ctx.res.set).to.have.been.calledWith(
  146. 'Content-Length',
  147. ctx.contentLength
  148. )
  149. })
  150. })
  151. describe('proxyToHistoryApiAndInjectUserDetails', function () {
  152. beforeEach(async function (ctx) {
  153. ctx.req = { url: '/mock/url', method: 'POST' }
  154. ctx.res = { json: sinon.stub() }
  155. ctx.data = 'mock-data'
  156. ctx.dataWithUsers = 'mock-injected-data'
  157. ctx.fetchJson.resolves(ctx.data)
  158. ctx.HistoryManager.promises.injectUserDetails.resolves(ctx.dataWithUsers)
  159. await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
  160. ctx.req,
  161. ctx.res,
  162. ctx.next
  163. )
  164. })
  165. it('should get the user id', function (ctx) {
  166. ctx.SessionManager.getLoggedInUserId.should.have.been.calledWith(
  167. ctx.req.session
  168. )
  169. })
  170. it('should call the project history api', function (ctx) {
  171. ctx.fetchJson.should.have.been.calledWith(
  172. `${ctx.settings.apis.project_history.url}${ctx.req.url}`,
  173. {
  174. method: ctx.req.method,
  175. headers: {
  176. 'X-User-Id': ctx.user_id,
  177. },
  178. }
  179. )
  180. })
  181. it('should inject the user data', function (ctx) {
  182. ctx.HistoryManager.promises.injectUserDetails.should.have.been.calledWith(
  183. ctx.data
  184. )
  185. })
  186. it('should return the data with users to the client', function (ctx) {
  187. ctx.res.json.should.have.been.calledWith(ctx.dataWithUsers)
  188. })
  189. })
  190. describe('proxyToHistoryApiAndInjectUserDetails (with the history API failing)', function () {
  191. beforeEach(async function (ctx) {
  192. ctx.url = '/mock/url'
  193. ctx.req = { url: ctx.url, method: 'POST' }
  194. ctx.res = { json: sinon.stub() }
  195. ctx.err = new RequestFailedError(ctx.url, {}, { status: 500 })
  196. ctx.fetchJson.rejects(ctx.err)
  197. await ctx.HistoryController.proxyToHistoryApiAndInjectUserDetails(
  198. ctx.req,
  199. ctx.res,
  200. ctx.next
  201. )
  202. })
  203. it('should not inject the user data', function (ctx) {
  204. ctx.HistoryManager.promises.injectUserDetails.should.not.have.been.called
  205. })
  206. it('should not return the data with users to the client', function (ctx) {
  207. ctx.res.json.should.not.have.been.called
  208. })
  209. it('should throw an error', function (ctx) {
  210. ctx.next.should.have.been.calledWith(ctx.err)
  211. })
  212. })
  213. describe('resyncProjectHistory', function () {
  214. describe('for a project without project-history enabled', function () {
  215. beforeEach(async function (ctx) {
  216. ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
  217. ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
  218. ctx.error = new Errors.ProjectHistoryDisabledError()
  219. ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.rejects(
  220. ctx.error
  221. )
  222. await ctx.HistoryController.resyncProjectHistory(
  223. ctx.req,
  224. ctx.res,
  225. ctx.next
  226. )
  227. })
  228. it('response with a 404', function (ctx) {
  229. ctx.res.sendStatus.should.have.been.calledWith(404)
  230. })
  231. })
  232. describe('for a project with project-history enabled', function () {
  233. beforeEach(async function (ctx) {
  234. ctx.req = { params: { Project_id: ctx.project_id }, body: {} }
  235. ctx.res = { setTimeout: sinon.stub(), sendStatus: sinon.stub() }
  236. await ctx.HistoryController.resyncProjectHistory(
  237. ctx.req,
  238. ctx.res,
  239. ctx.next
  240. )
  241. })
  242. it('sets an extended response timeout', function (ctx) {
  243. ctx.res.setTimeout.should.have.been.calledWith(6 * 60 * 1000)
  244. })
  245. it('resyncs the project', function (ctx) {
  246. ctx.ProjectEntityUpdateHandler.promises.resyncProjectHistory.should.have.been.calledWith(
  247. ctx.project_id
  248. )
  249. })
  250. it('responds with a 204', function (ctx) {
  251. ctx.res.sendStatus.should.have.been.calledWith(204)
  252. })
  253. })
  254. })
  255. describe('requestBlob', function () {
  256. describe('With Range header', function () {
  257. beforeEach(async function (ctx) {
  258. ctx.req = {
  259. params: { project_id: ctx.project_id, hash: 'foo' },
  260. body: {},
  261. get: sinon.stub(),
  262. }
  263. ctx.req.get.withArgs('Range').returns('bytes=0-42')
  264. ctx.res = { setHeader: sinon.stub(), status: sinon.stub() }
  265. ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
  266. stream: null,
  267. contentLength: '43',
  268. contentRange: 'bytes 0-42/100',
  269. })
  270. await ctx.HistoryController.getBlob(ctx.req, ctx.res, ctx.next)
  271. })
  272. it('should forward the range request', function (ctx) {
  273. ctx.HistoryManager.promises.requestBlobWithProjectId.should.have.been.calledWith(
  274. ctx.project_id,
  275. 'foo',
  276. 'GET',
  277. 'bytes=0-42'
  278. )
  279. })
  280. it('should forward the Content-Range header', function (ctx) {
  281. ctx.res.setHeader.should.have.been.calledWith(
  282. 'Content-Range',
  283. 'bytes 0-42/100'
  284. )
  285. })
  286. it('should forward the Content-Length header', function (ctx) {
  287. ctx.res.setHeader.should.have.been.calledWith('Content-Length', '43')
  288. })
  289. it('should have status 206', function (ctx) {
  290. ctx.res.status.should.have.been.calledWith(206)
  291. })
  292. })
  293. describe('Without Range header', function () {
  294. beforeEach(async function (ctx) {
  295. ctx.req = {
  296. params: { project_id: ctx.project_id, hash: 'foo' },
  297. body: {},
  298. get: sinon.stub(),
  299. }
  300. ctx.req.get.withArgs('Range').returns(null)
  301. ctx.res = { setHeader: sinon.stub(), status: sinon.stub() }
  302. ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
  303. stream: null,
  304. contentLength: '100',
  305. range: null,
  306. })
  307. await ctx.HistoryController.getBlob(ctx.req, ctx.res, ctx.next)
  308. })
  309. it('should not have a Content-Range header', function (ctx) {
  310. expect(ctx.res.setHeader).to.not.have.been.calledWith(
  311. 'Content-Range',
  312. sinon.match.string
  313. )
  314. })
  315. it('should forward the Content-Length header', function (ctx) {
  316. ctx.res.setHeader.should.have.been.calledWith('Content-Length', '100')
  317. })
  318. it('should not have status 206', function (ctx) {
  319. ctx.res.status.should.not.have.been.calledWith(206)
  320. })
  321. })
  322. })
  323. })