FileStoreControllerTests.mjs 7.3 KB

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