FileStoreHandlerTests.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. const { assert } = require('chai')
  2. const sinon = require('sinon')
  3. const { expect } = require('chai')
  4. const SandboxedModule = require('sandboxed-module')
  5. const Errors = require('../../../../app/src/Features/Errors/Errors')
  6. const OError = require('@overleaf/o-error')
  7. const MODULE_PATH = '../../../../app/src/Features/FileStore/FileStoreHandler.js'
  8. describe('FileStoreHandler', function() {
  9. beforeEach(function() {
  10. this.fs = {
  11. createReadStream: sinon.stub(),
  12. lstat: sinon.stub().callsArgWith(1, null, {
  13. isFile() {
  14. return true
  15. },
  16. isDirectory() {
  17. return false
  18. }
  19. })
  20. }
  21. this.writeStream = {
  22. my: 'writeStream',
  23. on(type, cb) {
  24. if (type === 'response') {
  25. // eslint-disable-next-line standard/no-callback-literal
  26. cb({ statusCode: 200 })
  27. }
  28. }
  29. }
  30. this.readStream = { my: 'readStream', on: sinon.stub() }
  31. this.request = sinon.stub()
  32. this.request.head = sinon.stub()
  33. this.filestoreUrl = 'http://filestore.sharelatex.test'
  34. this.settings = {
  35. apis: { filestore: { url: this.filestoreUrl } }
  36. }
  37. this.hashValue = '0123456789'
  38. this.fileArgs = { name: 'upload-filename' }
  39. this.fileId = 'file_id_here'
  40. this.projectId = '1312312312'
  41. this.fsPath = 'uploads/myfile.eps'
  42. this.getFileUrl = (projectId, fileId) =>
  43. `${this.filestoreUrl}/project/${projectId}/file/${fileId}`
  44. this.getProjectUrl = projectId =>
  45. `${this.filestoreUrl}/project/${projectId}`
  46. this.FileModel = class File {
  47. constructor(options) {
  48. ;({ name: this.name, hash: this.hash } = options)
  49. this._id = 'file_id_here'
  50. this.rev = 0
  51. if (options.linkedFileData != null) {
  52. this.linkedFileData = options.linkedFileData
  53. }
  54. }
  55. }
  56. this.logger = {
  57. log: sinon.stub(),
  58. warn: sinon.stub(),
  59. err: sinon.stub()
  60. }
  61. this.FileHashManager = {
  62. computeHash: sinon.stub().callsArgWith(1, null, this.hashValue)
  63. }
  64. this.handler = SandboxedModule.require(MODULE_PATH, {
  65. globals: {
  66. console: console
  67. },
  68. requires: {
  69. 'settings-sharelatex': this.settings,
  70. request: this.request,
  71. 'logger-sharelatex': this.logger,
  72. './FileHashManager': this.FileHashManager,
  73. // FIXME: need to stub File object here
  74. '../../models/File': {
  75. File: this.FileModel
  76. },
  77. '../Errors/Errors': Errors,
  78. fs: this.fs
  79. }
  80. })
  81. })
  82. describe('uploadFileFromDisk', function() {
  83. beforeEach(function() {
  84. this.request.returns(this.writeStream)
  85. })
  86. it('should create read stream', function(done) {
  87. this.fs.createReadStream.returns({
  88. pipe() {},
  89. on(type, cb) {
  90. if (type === 'open') {
  91. cb()
  92. }
  93. }
  94. })
  95. this.handler.uploadFileFromDisk(
  96. this.projectId,
  97. this.fileArgs,
  98. this.fsPath,
  99. () => {
  100. this.fs.createReadStream.calledWith(this.fsPath).should.equal(true)
  101. done()
  102. }
  103. )
  104. })
  105. it('should pipe the read stream to request', function(done) {
  106. this.request.returns(this.writeStream)
  107. this.fs.createReadStream.returns({
  108. on(type, cb) {
  109. if (type === 'open') {
  110. cb()
  111. }
  112. },
  113. pipe: o => {
  114. this.writeStream.should.equal(o)
  115. done()
  116. }
  117. })
  118. this.handler.uploadFileFromDisk(
  119. this.projectId,
  120. this.fileArgs,
  121. this.fsPath,
  122. () => {}
  123. )
  124. })
  125. it('should pass the correct options to request', function(done) {
  126. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  127. this.fs.createReadStream.returns({
  128. pipe() {},
  129. on(type, cb) {
  130. if (type === 'open') {
  131. cb()
  132. }
  133. }
  134. })
  135. this.handler.uploadFileFromDisk(
  136. this.projectId,
  137. this.fileArgs,
  138. this.fsPath,
  139. () => {
  140. this.request.args[0][0].method.should.equal('post')
  141. this.request.args[0][0].uri.should.equal(fileUrl)
  142. done()
  143. }
  144. )
  145. })
  146. it('should callback with the url and fileRef', function(done) {
  147. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  148. this.fs.createReadStream.returns({
  149. pipe() {},
  150. on(type, cb) {
  151. if (type === 'open') {
  152. cb()
  153. }
  154. }
  155. })
  156. this.handler.uploadFileFromDisk(
  157. this.projectId,
  158. this.fileArgs,
  159. this.fsPath,
  160. (err, url, fileRef) => {
  161. expect(err).to.not.exist
  162. expect(url).to.equal(fileUrl)
  163. expect(fileRef._id).to.equal(this.fileId)
  164. expect(fileRef.hash).to.equal(this.hashValue)
  165. done()
  166. }
  167. )
  168. })
  169. describe('symlink', function() {
  170. it('should not read file if it is symlink', function(done) {
  171. this.fs.lstat = sinon.stub().callsArgWith(1, null, {
  172. isFile() {
  173. return false
  174. },
  175. isDirectory() {
  176. return false
  177. }
  178. })
  179. this.handler.uploadFileFromDisk(
  180. this.projectId,
  181. this.fileArgs,
  182. this.fsPath,
  183. () => {
  184. this.fs.createReadStream.called.should.equal(false)
  185. done()
  186. }
  187. )
  188. })
  189. it('should not read file stat returns nothing', function(done) {
  190. this.fs.lstat = sinon.stub().callsArgWith(1, null, null)
  191. this.handler.uploadFileFromDisk(
  192. this.projectId,
  193. this.fileArgs,
  194. this.fsPath,
  195. () => {
  196. this.fs.createReadStream.called.should.equal(false)
  197. done()
  198. }
  199. )
  200. })
  201. })
  202. describe('when upload fails', function() {
  203. beforeEach(function() {
  204. this.writeStream.on = function(type, cb) {
  205. if (type === 'response') {
  206. // eslint-disable-next-line standard/no-callback-literal
  207. cb({ statusCode: 500 })
  208. }
  209. }
  210. })
  211. it('should callback with an error', function(done) {
  212. this.fs.createReadStream.callCount = 0
  213. this.fs.createReadStream.returns({
  214. pipe() {},
  215. on(type, cb) {
  216. if (type === 'open') {
  217. cb()
  218. }
  219. }
  220. })
  221. this.handler.uploadFileFromDisk(
  222. this.projectId,
  223. this.fileArgs,
  224. this.fsPath,
  225. err => {
  226. expect(err).to.exist
  227. expect(err).to.be.instanceof(Error)
  228. expect(this.fs.createReadStream.callCount).to.equal(
  229. this.handler.RETRY_ATTEMPTS
  230. )
  231. done()
  232. }
  233. )
  234. })
  235. })
  236. })
  237. describe('deleteFile', function() {
  238. it('should send a delete request to filestore api', function(done) {
  239. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  240. this.request.callsArgWith(1, null)
  241. this.handler.deleteFile(this.projectId, this.fileId, err => {
  242. assert.equal(err, undefined)
  243. this.request.args[0][0].method.should.equal('delete')
  244. this.request.args[0][0].uri.should.equal(fileUrl)
  245. done()
  246. })
  247. })
  248. it('should return the error if there is one', function(done) {
  249. const error = 'my error'
  250. this.request.callsArgWith(1, error)
  251. this.handler.deleteFile(this.projectId, this.fileId, err => {
  252. assert.equal(err, error)
  253. done()
  254. })
  255. })
  256. })
  257. describe('deleteProject', function() {
  258. it('should send a delete request to filestore api', function(done) {
  259. const projectUrl = this.getProjectUrl(this.projectId)
  260. this.request.callsArgWith(1, null)
  261. this.handler.deleteProject(this.projectId, err => {
  262. assert.equal(err, undefined)
  263. this.request.args[0][0].method.should.equal('delete')
  264. this.request.args[0][0].uri.should.equal(projectUrl)
  265. done()
  266. })
  267. })
  268. it('should wrap the error if there is one', function(done) {
  269. const error = new Error('my error')
  270. this.request.callsArgWith(1, error)
  271. this.handler.deleteProject(this.projectId, err => {
  272. expect(OError.getFullStack(err)).to.match(
  273. /something went wrong deleting a project in filestore/
  274. )
  275. expect(OError.getFullStack(err)).to.match(/my error/)
  276. done()
  277. })
  278. })
  279. })
  280. describe('getFileStream', function() {
  281. beforeEach(function() {
  282. this.query = {}
  283. this.request.returns(this.readStream)
  284. })
  285. it('should get the stream with the correct params', function(done) {
  286. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  287. this.handler.getFileStream(
  288. this.projectId,
  289. this.fileId,
  290. this.query,
  291. (err, stream) => {
  292. if (err) {
  293. return done(err)
  294. }
  295. this.request.args[0][0].method.should.equal('get')
  296. this.request.args[0][0].uri.should.equal(fileUrl)
  297. done()
  298. }
  299. )
  300. })
  301. it('should get stream from request', function(done) {
  302. this.handler.getFileStream(
  303. this.projectId,
  304. this.fileId,
  305. this.query,
  306. (err, stream) => {
  307. if (err) {
  308. return done(err)
  309. }
  310. stream.should.equal(this.readStream)
  311. done()
  312. }
  313. )
  314. })
  315. it('should add an error handler', function(done) {
  316. this.handler.getFileStream(
  317. this.projectId,
  318. this.fileId,
  319. this.query,
  320. (err, stream) => {
  321. if (err) {
  322. return done(err)
  323. }
  324. stream.on.calledWith('error').should.equal(true)
  325. done()
  326. }
  327. )
  328. })
  329. describe('when range is specified in query', function() {
  330. beforeEach(function() {
  331. this.query = { range: '0-10' }
  332. })
  333. it('should add a range header', function(done) {
  334. this.handler.getFileStream(
  335. this.projectId,
  336. this.fileId,
  337. this.query,
  338. (err, stream) => {
  339. if (err) {
  340. return done(err)
  341. }
  342. this.request.callCount.should.equal(1)
  343. const { headers } = this.request.firstCall.args[0]
  344. expect(headers).to.have.keys('range')
  345. expect(headers['range']).to.equal('bytes=0-10')
  346. done()
  347. }
  348. )
  349. })
  350. describe('when range is invalid', function() {
  351. ;['0-', '-100', 'one-two', 'nonsense'].forEach(r => {
  352. beforeEach(function() {
  353. this.query = { range: `${r}` }
  354. })
  355. it(`should not add a range header for '${r}'`, function(done) {
  356. this.handler.getFileStream(
  357. this.projectId,
  358. this.fileId,
  359. this.query,
  360. (err, stream) => {
  361. if (err) {
  362. return done(err)
  363. }
  364. this.request.callCount.should.equal(1)
  365. const { headers } = this.request.firstCall.args[0]
  366. expect(headers).to.not.have.keys('range')
  367. done()
  368. }
  369. )
  370. })
  371. })
  372. })
  373. })
  374. })
  375. describe('getFileSize', function() {
  376. it('returns the file size reported by filestore', function(done) {
  377. const expectedFileSize = 32432
  378. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  379. this.request.head.yields(
  380. new Error('request.head() received unexpected arguments')
  381. )
  382. this.request.head.withArgs(fileUrl).yields(null, {
  383. statusCode: 200,
  384. headers: {
  385. 'content-length': expectedFileSize
  386. }
  387. })
  388. this.handler.getFileSize(this.projectId, this.fileId, (err, fileSize) => {
  389. if (err) {
  390. return done(err)
  391. }
  392. expect(fileSize).to.equal(expectedFileSize)
  393. done()
  394. })
  395. })
  396. it('throws a NotFoundError on a 404 from filestore', function(done) {
  397. this.request.head.yields(null, { statusCode: 404 })
  398. this.handler.getFileSize(this.projectId, this.fileId, err => {
  399. expect(err).to.be.instanceof(Errors.NotFoundError)
  400. done()
  401. })
  402. })
  403. it('throws an error on a non-200 from filestore', function(done) {
  404. this.request.head.yields(null, { statusCode: 500 })
  405. this.handler.getFileSize(this.projectId, this.fileId, err => {
  406. expect(err).to.be.instanceof(Error)
  407. done()
  408. })
  409. })
  410. it('rethrows errors from filestore', function(done) {
  411. this.request.head.yields(new Error())
  412. this.handler.getFileSize(this.projectId, this.fileId, err => {
  413. expect(err).to.be.instanceof(Error)
  414. done()
  415. })
  416. })
  417. })
  418. describe('copyFile', function() {
  419. beforeEach(function() {
  420. this.newProjectId = 'new project'
  421. this.newFileId = 'new file id'
  422. })
  423. it('should post json', function(done) {
  424. const newFileUrl = this.getFileUrl(this.newProjectId, this.newFileId)
  425. this.request.callsArgWith(1, null, { statusCode: 200 })
  426. this.handler.copyFile(
  427. this.projectId,
  428. this.fileId,
  429. this.newProjectId,
  430. this.newFileId,
  431. () => {
  432. this.request.args[0][0].method.should.equal('put')
  433. this.request.args[0][0].uri.should.equal(newFileUrl)
  434. this.request.args[0][0].json.source.project_id.should.equal(
  435. this.projectId
  436. )
  437. this.request.args[0][0].json.source.file_id.should.equal(this.fileId)
  438. done()
  439. }
  440. )
  441. })
  442. it('returns the url', function(done) {
  443. const expectedUrl = this.getFileUrl(this.newProjectId, this.newFileId)
  444. this.request.callsArgWith(1, null, { statusCode: 200 })
  445. this.handler.copyFile(
  446. this.projectId,
  447. this.fileId,
  448. this.newProjectId,
  449. this.newFileId,
  450. (err, url) => {
  451. if (err) {
  452. return done(err)
  453. }
  454. url.should.equal(expectedUrl)
  455. done()
  456. }
  457. )
  458. })
  459. it('should return the err', function(done) {
  460. const error = 'errrror'
  461. this.request.callsArgWith(1, error)
  462. this.handler.copyFile(
  463. this.projectId,
  464. this.fileId,
  465. this.newProjectId,
  466. this.newFileId,
  467. err => {
  468. err.should.equal(error)
  469. done()
  470. }
  471. )
  472. })
  473. it('should return an error for a non-success statusCode', function(done) {
  474. this.request.callsArgWith(1, null, { statusCode: 500 })
  475. this.handler.copyFile(
  476. this.projectId,
  477. this.fileId,
  478. this.newProjectId,
  479. this.newFileId,
  480. err => {
  481. err.should.be.an('error')
  482. err.message.should.equal(
  483. 'non-ok response from filestore for copyFile: 500'
  484. )
  485. done()
  486. }
  487. )
  488. })
  489. })
  490. })