HistoryTests.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import fs from 'node:fs'
  2. import Path from 'node:path'
  3. import { expect } from 'chai'
  4. import UserHelper from './helpers/User.mjs'
  5. import MockV1HistoryApiClass from './mocks/MockV1HistoryApi.mjs'
  6. import ProjectGetter from '../../../app/src/Features/Project/ProjectGetter.js'
  7. import MockFilestoreApiClass from './mocks/MockFilestoreApi.mjs'
  8. import { fileURLToPath } from 'node:url'
  9. import sinon from 'sinon'
  10. import logger from '@overleaf/logger'
  11. import Metrics from './helpers/metrics.mjs'
  12. import Features from '../../../app/src/infrastructure/Features.js'
  13. const User = UserHelper.promises
  14. let MockV1HistoryApi, MockFilestoreApi
  15. before(function () {
  16. MockV1HistoryApi = MockV1HistoryApiClass.instance()
  17. MockFilestoreApi = MockFilestoreApiClass.instance()
  18. })
  19. const __dirname = fileURLToPath(new URL('.', import.meta.url))
  20. const fileContent = fs.readFileSync(
  21. Path.join(__dirname, '../files/2pixel.png'),
  22. 'utf-8'
  23. )
  24. describe('HistoryTests', function () {
  25. let user, projectId, fileId, fileHash, fileURL, blobURL, blobURLWithFallback
  26. let historySource, filestoreSource
  27. async function getSourceMetric(source) {
  28. return await Metrics.promises.getMetric(
  29. line => line.includes('request_blob') && line.includes(source)
  30. )
  31. }
  32. beforeEach('create project', async function () {
  33. user = new User()
  34. await user.login()
  35. projectId = await user.createProject('project1')
  36. const project = await ProjectGetter.promises.getProject(projectId)
  37. ;({ entity_id: fileId, hash: fileHash } =
  38. await user.uploadFileInProjectFull(
  39. projectId,
  40. project.rootFolder[0]._id.toString(),
  41. '2pixel.png',
  42. '2pixel.png',
  43. 'image/png'
  44. ))
  45. fileURL = `/project/${projectId}/file/${fileId}`
  46. blobURL = `/project/${projectId}/blob/${fileHash}`
  47. blobURLWithFallback = `${blobURL}?fallback=${fileId}`
  48. historySource = await getSourceMetric('history-v1')
  49. filestoreSource = await getSourceMetric('filestore')
  50. })
  51. async function expectHistoryV1Hit() {
  52. expect(await getSourceMetric('history-v1')).to.equal(historySource + 1)
  53. expect(await getSourceMetric('filestore')).to.equal(filestoreSource)
  54. }
  55. async function expectFilestoreHit() {
  56. expect(await getSourceMetric('history-v1')).to.equal(historySource)
  57. expect(await getSourceMetric('filestore')).to.equal(filestoreSource + 1)
  58. }
  59. async function expectNoIncrement() {
  60. expect(await getSourceMetric('history-v1')).to.equal(historySource)
  61. expect(await getSourceMetric('filestore')).to.equal(filestoreSource)
  62. }
  63. describe('/project/:projectId/download/zip', function () {
  64. let spy, downloadZIPURL
  65. beforeEach(async function () {
  66. spy = sinon.spy(logger, 'error')
  67. downloadZIPURL = `/project/${projectId}/download/zip`
  68. })
  69. afterEach(function () {
  70. spy.restore()
  71. })
  72. if (Features.hasFeature('project-history-blobs')) {
  73. it('should work from history-v1', async function () {
  74. const { response, body } = await user.doRequest('GET', downloadZIPURL)
  75. expect(response.statusCode).to.equal(200)
  76. expect(body).to.include('2pixel.png')
  77. await expectHistoryV1Hit()
  78. })
  79. if (Features.hasFeature('filestore')) {
  80. it('should work from filestore', async function () {
  81. MockV1HistoryApi.reset()
  82. const { response, body } = await user.doRequest('GET', downloadZIPURL)
  83. expect(response.statusCode).to.equal(200)
  84. expect(body).to.include('2pixel.png')
  85. await expectFilestoreHit()
  86. })
  87. }
  88. it('should not include when missing in both places', async function () {
  89. MockFilestoreApi.reset()
  90. MockV1HistoryApi.reset()
  91. const { response, body } = await user.doRequest('GET', downloadZIPURL)
  92. expect(response.statusCode).to.equal(200)
  93. expect(
  94. spy.args.find(([, msg]) => msg === 'error adding files to zip stream')
  95. ).to.exist
  96. expect(body).to.not.include('2pixel.png')
  97. await expectNoIncrement()
  98. })
  99. }
  100. })
  101. describe('/project/:projectId/blob/:hash', function () {
  102. describe('HEAD', function () {
  103. if (Features.hasFeature('project-history-blobs')) {
  104. it('should fetch the file size from history-v1', async function () {
  105. const { response } = await user.doRequest('HEAD', blobURL)
  106. expect(response.statusCode).to.equal(200)
  107. expect(response.headers['x-served-by']).to.include('history-v1')
  108. expect(response.headers['content-length']).to.equal('3694')
  109. await expectHistoryV1Hit()
  110. })
  111. }
  112. it('should return 404 without fallback', async function () {
  113. MockV1HistoryApi.reset()
  114. const { response } = await user.doRequest('HEAD', blobURL)
  115. expect(response.statusCode).to.equal(404)
  116. await expectNoIncrement()
  117. })
  118. if (Features.hasFeature('filestore')) {
  119. it('should fetch the file size from filestore when missing in history-v1', async function () {
  120. MockV1HistoryApi.reset()
  121. const { response } = await user.doRequest('HEAD', blobURLWithFallback)
  122. expect(response.statusCode).to.equal(200)
  123. expect(response.headers['x-served-by']).to.include('filestore')
  124. expect(response.headers['content-length']).to.equal('3694')
  125. await expectFilestoreHit()
  126. })
  127. }
  128. it('should return 404 with both files missing', async function () {
  129. MockFilestoreApi.reset()
  130. MockV1HistoryApi.reset()
  131. const { response } = await user.doRequest('HEAD', blobURLWithFallback)
  132. expect(response.statusCode).to.equal(404)
  133. await expectNoIncrement()
  134. })
  135. })
  136. describe('GET', function () {
  137. if (Features.hasFeature('project-history-blobs')) {
  138. it('should fetch the file from history-v1', async function () {
  139. const { response, body } = await user.doRequest('GET', blobURL)
  140. expect(response.statusCode).to.equal(200)
  141. expect(response.headers['x-served-by']).to.include('history-v1')
  142. expect(body).to.equal(fileContent)
  143. await expectHistoryV1Hit()
  144. })
  145. it('should set cache headers', async function () {
  146. const { response } = await user.doRequest('GET', blobURL)
  147. expect(response.headers['cache-control']).to.equal(
  148. 'private, max-age=86400, stale-while-revalidate=31536000'
  149. )
  150. expect(response.headers.etag).to.equal(fileHash)
  151. })
  152. it('should return a 304 when revalidating', async function () {
  153. const { response, body } = await user.doRequest('GET', {
  154. url: blobURL,
  155. headers: { 'If-None-Match': fileHash },
  156. })
  157. expect(response.statusCode).to.equal(304)
  158. expect(response.headers.etag).to.equal(fileHash)
  159. expect(body).to.equal('')
  160. })
  161. }
  162. it('should return 404 without fallback', async function () {
  163. MockV1HistoryApi.reset()
  164. const { response } = await user.doRequest('GET', blobURL)
  165. expect(response.statusCode).to.equal(404)
  166. await expectNoIncrement()
  167. })
  168. it('should not set cache headers on 404', async function () {
  169. MockV1HistoryApi.reset()
  170. const { response } = await user.doRequest('GET', blobURL)
  171. expect(response.statusCode).to.equal(404)
  172. expect(response.headers).not.to.have.property('cache-control')
  173. expect(response.headers).not.to.have.property('etag')
  174. })
  175. if (Features.hasFeature('filestore')) {
  176. it('should fetch the file size from filestore when missing in history-v1', async function () {
  177. MockV1HistoryApi.reset()
  178. const { response, body } = await user.doRequest(
  179. 'GET',
  180. blobURLWithFallback
  181. )
  182. expect(response.statusCode).to.equal(200)
  183. expect(response.headers['x-served-by']).to.include('filestore')
  184. expect(body).to.equal(fileContent)
  185. await expectFilestoreHit()
  186. })
  187. }
  188. it('should return 404 with both files missing', async function () {
  189. MockFilestoreApi.reset()
  190. MockV1HistoryApi.reset()
  191. const { response } = await user.doRequest('GET', blobURLWithFallback)
  192. expect(response.statusCode).to.equal(404)
  193. await expectNoIncrement()
  194. })
  195. })
  196. })
  197. // Legacy endpoint that is powered by history-v1 in SaaS
  198. describe('/project/:projectId/file/:fileId', function () {
  199. describe('HEAD', function () {
  200. if (Features.hasFeature('project-history-blobs')) {
  201. it('should fetch the file size from history-v1', async function () {
  202. const { response } = await user.doRequest('HEAD', fileURL)
  203. expect(response.statusCode).to.equal(200)
  204. expect(response.headers['x-served-by']).to.include('history-v1')
  205. expect(response.headers['content-length']).to.equal('3694')
  206. await expectHistoryV1Hit()
  207. })
  208. }
  209. if (Features.hasFeature('filestore')) {
  210. it('should fetch the file size from filestore when missing in history-v1', async function () {
  211. MockV1HistoryApi.reset()
  212. const { response } = await user.doRequest('HEAD', blobURLWithFallback)
  213. expect(response.statusCode).to.equal(200)
  214. expect(response.headers['x-served-by']).to.include('filestore')
  215. expect(response.headers['content-length']).to.equal('3694')
  216. })
  217. }
  218. it('should return 404 with both files missing', async function () {
  219. MockFilestoreApi.reset()
  220. MockV1HistoryApi.reset()
  221. const { response } = await user.doRequest('HEAD', blobURLWithFallback)
  222. expect(response.statusCode).to.equal(404)
  223. })
  224. })
  225. describe('GET', function () {
  226. if (Features.hasFeature('project-history-blobs')) {
  227. it('should fetch the file from history-v1', async function () {
  228. const { response, body } = await user.doRequest('GET', fileURL)
  229. expect(response.statusCode).to.equal(200)
  230. expect(response.headers['x-served-by']).to.include('history-v1')
  231. expect(body).to.equal(fileContent)
  232. await expectHistoryV1Hit()
  233. })
  234. }
  235. it('should set cache headers', async function () {
  236. const { response } = await user.doRequest('GET', fileURL)
  237. expect(response.headers['cache-control']).to.equal(
  238. 'private, max-age=3600'
  239. )
  240. })
  241. it('should not set cache headers on 404', async function () {
  242. MockV1HistoryApi.reset()
  243. MockFilestoreApi.reset()
  244. // The legacy filestore downloads are not properly handling 404s, so delete the file from the file-tree to trigger the 404. All the filestore code will be removed soon.
  245. await user.doRequest('DELETE', fileURL)
  246. const { response } = await user.doRequest('GET', fileURL)
  247. expect(response.statusCode).to.equal(404)
  248. expect(response.headers).not.to.have.property('cache-control')
  249. expect(response.headers).not.to.have.property('etag')
  250. })
  251. if (Features.hasFeature('filestore')) {
  252. it('should fetch the file size from filestore when missing in history-v1', async function () {
  253. MockV1HistoryApi.reset()
  254. const { response, body } = await user.doRequest('GET', fileURL)
  255. expect(response.statusCode).to.equal(200)
  256. expect(response.headers['x-served-by']).to.include('filestore')
  257. expect(body).to.equal(fileContent)
  258. })
  259. }
  260. it('should return 404 with both files missing', async function () {
  261. MockFilestoreApi.reset()
  262. MockV1HistoryApi.reset()
  263. const { response } = await user.doRequest('GET', fileURL)
  264. expect(response.statusCode).to.equal(404)
  265. })
  266. })
  267. })
  268. })