FileStoreController.test.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. import { expect, vi } from 'vitest'
  2. import sinon from 'sinon'
  3. import Errors from '../../../../app/src/Features/Errors/Errors.js'
  4. import MockResponse from '../helpers/MockResponse.js'
  5. const MODULE_PATH =
  6. '../../../../app/src/Features/FileStore/FileStoreController.mjs'
  7. const expectedFileHeaders = {
  8. 'Cache-Control': 'private, max-age=3600',
  9. }
  10. vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
  11. vi.importActual('../../../../app/src/Features/Errors/Errors.js')
  12. )
  13. describe('FileStoreController', function () {
  14. beforeEach(async function (ctx) {
  15. ctx.ProjectLocator = { promises: { findElement: sinon.stub() } }
  16. ctx.Stream = { pipeline: sinon.stub().resolves() }
  17. ctx.HistoryManager = {
  18. promises: { requestBlobWithProjectId: sinon.stub() },
  19. }
  20. vi.doMock('node:stream/promises', () => ctx.Stream)
  21. vi.doMock('@overleaf/settings', () => ({
  22. default: ctx.settings,
  23. }))
  24. vi.doMock('../../../../app/src/Features/Project/ProjectLocator', () => ({
  25. default: ctx.ProjectLocator,
  26. }))
  27. vi.doMock('../../../../app/src/Features/History/HistoryManager', () => ({
  28. default: ctx.HistoryManager,
  29. }))
  30. ctx.controller = (await import(MODULE_PATH)).default
  31. ctx.stream = {}
  32. ctx.projectId = '2k3j1lk3j21lk3j'
  33. ctx.fileId = '12321kklj1lk3jk12'
  34. ctx.req = {
  35. params: {
  36. Project_id: ctx.projectId,
  37. File_id: ctx.fileId,
  38. },
  39. query: 'query string here',
  40. get(key) {
  41. return undefined
  42. },
  43. logger: {
  44. addFields: sinon.stub(),
  45. },
  46. }
  47. ctx.res = new MockResponse()
  48. ctx.next = sinon.stub()
  49. ctx.hash = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
  50. ctx.file = { name: 'myfile.png', hash: ctx.hash }
  51. })
  52. describe('getFile', function () {
  53. beforeEach(function (ctx) {
  54. ctx.HistoryManager.promises.requestBlobWithProjectId.resolves({
  55. stream: ctx.stream,
  56. })
  57. ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file })
  58. })
  59. it('should call the history manager with the project_id hash', async function (ctx) {
  60. await ctx.controller.getFile(ctx.req, ctx.res)
  61. ctx.HistoryManager.promises.requestBlobWithProjectId.should.have.been.calledWith(
  62. ctx.req.params.Project_id,
  63. ctx.hash,
  64. 'GET'
  65. )
  66. })
  67. it('should pipe to res', async function (ctx) {
  68. await ctx.controller.getFile(ctx.req, ctx.res)
  69. ctx.Stream.pipeline.should.have.been.calledWith(ctx.stream, ctx.res)
  70. })
  71. it('should get the file from the db', async function (ctx) {
  72. await ctx.controller.getFile(ctx.req, ctx.res)
  73. ctx.ProjectLocator.promises.findElement.should.have.been.calledWith({
  74. project_id: ctx.projectId,
  75. element_id: ctx.fileId,
  76. type: 'file',
  77. })
  78. })
  79. it('should set the Content-Disposition header', async function (ctx) {
  80. await ctx.controller.getFile(ctx.req, ctx.res)
  81. ctx.res.setContentDisposition.should.be.calledWith('attachment', {
  82. filename: ctx.file.name,
  83. })
  84. })
  85. it('should return a 404 when not found', async function (ctx) {
  86. ctx.ProjectLocator.promises.findElement.rejects(
  87. new Errors.NotFoundError()
  88. )
  89. await ctx.controller.getFile(ctx.req, ctx.res)
  90. expect(ctx.res.statusCode).to.equal(404)
  91. })
  92. // Test behaviour around handling html files
  93. ;['.html', '.htm', '.xhtml'].forEach(extension => {
  94. describe(`with a '${extension}' file extension`, function () {
  95. beforeEach(function (ctx) {
  96. ctx.file.name = `bad${extension}`
  97. ctx.req.get = key => {
  98. if (key === 'User-Agent') {
  99. return 'A generic browser'
  100. }
  101. }
  102. })
  103. describe('from a non-ios browser', function () {
  104. it('should not set Content-Type', async function (ctx) {
  105. await ctx.controller.getFile(ctx.req, ctx.res)
  106. ctx.res.headers.should.deep.equal({
  107. ...expectedFileHeaders,
  108. })
  109. })
  110. })
  111. describe('from an iPhone', function () {
  112. beforeEach(function (ctx) {
  113. ctx.req.get = key => {
  114. if (key === 'User-Agent') {
  115. return 'An iPhone browser'
  116. }
  117. }
  118. })
  119. it("should set Content-Type to 'text/plain'", async function (ctx) {
  120. await ctx.controller.getFile(ctx.req, ctx.res)
  121. ctx.res.headers.should.deep.equal({
  122. ...expectedFileHeaders,
  123. 'Content-Type': 'text/plain; charset=utf-8',
  124. 'X-Content-Type-Options': 'nosniff',
  125. })
  126. })
  127. })
  128. describe('from an iPad', function () {
  129. beforeEach(function (ctx) {
  130. ctx.req.get = key => {
  131. if (key === 'User-Agent') {
  132. return 'An iPad browser'
  133. }
  134. }
  135. })
  136. it("should set Content-Type to 'text/plain'", async function (ctx) {
  137. await ctx.controller.getFile(ctx.req, ctx.res)
  138. ctx.res.headers.should.deep.equal({
  139. ...expectedFileHeaders,
  140. 'Content-Type': 'text/plain; charset=utf-8',
  141. 'X-Content-Type-Options': 'nosniff',
  142. })
  143. })
  144. })
  145. })
  146. })
  147. ;[
  148. // None of these should trigger the iOS/html logic
  149. 'x.html-is-rad',
  150. 'html.pdf',
  151. '.html-is-good-for-hidden-files',
  152. 'somefile',
  153. ].forEach(filename => {
  154. describe(`with filename as '${filename}'`, function () {
  155. beforeEach(function (ctx) {
  156. ctx.user_agent = 'A generic browser'
  157. ctx.file.name = filename
  158. ctx.req.get = key => {
  159. if (key === 'User-Agent') {
  160. return ctx.user_agent
  161. }
  162. }
  163. })
  164. ;['iPhone', 'iPad', 'Firefox', 'Chrome'].forEach(browser => {
  165. describe(`downloaded from ${browser}`, function () {
  166. beforeEach(function (ctx) {
  167. ctx.user_agent = `Some ${browser} thing`
  168. })
  169. it('Should not set the Content-type', async function (ctx) {
  170. await ctx.controller.getFile(ctx.req, ctx.res)
  171. ctx.res.headers.should.deep.equal({
  172. ...expectedFileHeaders,
  173. })
  174. })
  175. })
  176. })
  177. })
  178. })
  179. })
  180. describe('getFileHead', function () {
  181. beforeEach(function (ctx) {
  182. ctx.ProjectLocator.promises.findElement.resolves({ element: ctx.file })
  183. })
  184. it('reports the file size', async function (ctx) {
  185. await new Promise(resolve => {
  186. const expectedFileSize = 99393
  187. ctx.HistoryManager.promises.requestBlobWithProjectId.rejects(
  188. new Error('getFileSize: unexpected arguments')
  189. )
  190. ctx.HistoryManager.promises.requestBlobWithProjectId
  191. .withArgs(ctx.projectId, ctx.hash)
  192. .resolves({ contentLength: expectedFileSize })
  193. ctx.res.end = () => {
  194. expect(ctx.res.status.lastCall.args).to.deep.equal([200])
  195. expect(ctx.res.header.lastCall.args).to.deep.equal([
  196. 'Content-Length',
  197. expectedFileSize,
  198. ])
  199. resolve()
  200. }
  201. ctx.controller.getFileHead(ctx.req, ctx.res)
  202. })
  203. })
  204. it('returns 404 on NotFoundError', async function (ctx) {
  205. await new Promise(resolve => {
  206. ctx.HistoryManager.promises.requestBlobWithProjectId.rejects(
  207. new Errors.NotFoundError()
  208. )
  209. ctx.res.end = () => {
  210. expect(ctx.res.status.lastCall.args).to.deep.equal([404])
  211. resolve()
  212. }
  213. ctx.controller.getFileHead(ctx.req, ctx.res)
  214. })
  215. })
  216. })
  217. })