HistoryController.test.mjs 12 KB

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