FileStoreHandlerTests.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  1. const { assert, expect } = require('chai')
  2. const sinon = require('sinon')
  3. const SandboxedModule = require('sandboxed-module')
  4. const Errors = require('../../../../app/src/Features/Errors/Errors')
  5. const OError = require('@overleaf/o-error')
  6. const MODULE_PATH = '../../../../app/src/Features/FileStore/FileStoreHandler.js'
  7. describe('FileStoreHandler', function () {
  8. beforeEach(function () {
  9. this.fileSize = 999
  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. size: this.fileSize,
  20. }),
  21. }
  22. this.writeStream = {
  23. my: 'writeStream',
  24. on(type, fn) {
  25. if (type === 'response') {
  26. fn({ 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.overleaf.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.historyId = 123
  42. this.hashValue = '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'
  43. this.fsPath = 'uploads/myfile.eps'
  44. this.getFileUrl = (projectId, fileId) =>
  45. `${this.filestoreUrl}/project/${projectId}/file/${fileId}`
  46. this.getProjectUrl = projectId =>
  47. `${this.filestoreUrl}/project/${projectId}`
  48. this.FileModel = class File {
  49. constructor(options) {
  50. ;({ name: this.name, hash: this.hash } = options)
  51. this._id = 'file_id_here'
  52. this.rev = 0
  53. if (options.linkedFileData != null) {
  54. this.linkedFileData = options.linkedFileData
  55. }
  56. }
  57. }
  58. this.FileHashManager = {
  59. computeHash: sinon.stub().callsArgWith(1, null, this.hashValue),
  60. }
  61. this.HistoryManager = {
  62. uploadBlobFromDisk: sinon.stub().callsArg(4),
  63. }
  64. this.ProjectDetailsHandler = {
  65. getDetails: sinon.stub().callsArgWith(1, null, {
  66. overleaf: { history: { id: this.historyId } },
  67. }),
  68. }
  69. this.Features = {
  70. hasFeature: sinon.stub(),
  71. }
  72. this.handler = SandboxedModule.require(MODULE_PATH, {
  73. requires: {
  74. '@overleaf/settings': this.settings,
  75. request: this.request,
  76. '../History/HistoryManager': this.HistoryManager,
  77. '../Project/ProjectDetailsHandler': this.ProjectDetailsHandler,
  78. './FileHashManager': this.FileHashManager,
  79. '../../infrastructure/Features': this.Features,
  80. // FIXME: need to stub File object here
  81. '../../models/File': {
  82. File: this.FileModel,
  83. },
  84. fs: this.fs,
  85. },
  86. })
  87. })
  88. describe('uploadFileFromDisk', function () {
  89. beforeEach(function () {
  90. this.request.returns(this.writeStream)
  91. })
  92. it('should get the project details', async function () {
  93. this.fs.createReadStream.returns({
  94. pipe() {},
  95. on(type, cb) {
  96. if (type === 'open') {
  97. cb()
  98. }
  99. },
  100. })
  101. await this.handler.promises.uploadFileFromDisk(
  102. this.projectId,
  103. this.fileArgs,
  104. this.fsPath
  105. )
  106. this.ProjectDetailsHandler.getDetails
  107. .calledWith(this.projectId)
  108. .should.equal(true)
  109. })
  110. it('should compute the file hash', async function () {
  111. this.fs.createReadStream.returns({
  112. pipe() {},
  113. on(type, cb) {
  114. if (type === 'open') {
  115. cb()
  116. }
  117. },
  118. })
  119. await this.handler.promises.uploadFileFromDisk(
  120. this.projectId,
  121. this.fileArgs,
  122. this.fsPath
  123. )
  124. this.FileHashManager.computeHash
  125. .calledWith(this.fsPath)
  126. .should.equal(true)
  127. })
  128. describe('when project-history-blobs feature is enabled', function () {
  129. it('should upload the file to the history store as a blob', async function () {
  130. this.fs.createReadStream.returns({
  131. pipe() {},
  132. on(type, cb) {
  133. if (type === 'open') {
  134. cb()
  135. }
  136. },
  137. })
  138. this.Features.hasFeature.withArgs('project-history-blobs').returns(true)
  139. await this.handler.promises.uploadFileFromDisk(
  140. this.projectId,
  141. this.fileArgs,
  142. this.fsPath
  143. )
  144. this.HistoryManager.uploadBlobFromDisk
  145. .calledWith(
  146. this.historyId,
  147. this.hashValue,
  148. this.fileSize,
  149. this.fsPath
  150. )
  151. .should.equal(true)
  152. })
  153. })
  154. describe('when project-history-blobs feature is disabled', function () {
  155. it('should not upload the file to the history store as a blob', async function () {
  156. this.fs.createReadStream.returns({
  157. pipe() {},
  158. on(type, cb) {
  159. if (type === 'open') {
  160. cb()
  161. }
  162. },
  163. })
  164. await this.handler.promises.uploadFileFromDisk(
  165. this.projectId,
  166. this.fileArgs,
  167. this.fsPath
  168. )
  169. this.HistoryManager.uploadBlobFromDisk.called.should.equal(false)
  170. })
  171. })
  172. describe('when filestore feature is enabled', function () {
  173. beforeEach(function () {
  174. this.Features.hasFeature.withArgs('filestore').returns(true)
  175. })
  176. it('should create read stream', function (done) {
  177. this.fs.createReadStream.returns({
  178. pipe() {},
  179. on(type, cb) {
  180. if (type === 'open') {
  181. cb()
  182. }
  183. },
  184. })
  185. this.handler.uploadFileFromDisk(
  186. this.projectId,
  187. this.fileArgs,
  188. this.fsPath,
  189. () => {
  190. this.fs.createReadStream.calledWith(this.fsPath).should.equal(true)
  191. done()
  192. }
  193. )
  194. })
  195. it('should pipe the read stream to request', function () {
  196. this.request.returns(this.writeStream)
  197. return new Promise((resolve, reject) => {
  198. this.fs.createReadStream.returns({
  199. on(type, cb) {
  200. if (type === 'open') {
  201. cb()
  202. }
  203. },
  204. pipe: o => {
  205. this.writeStream.should.equal(o)
  206. resolve()
  207. },
  208. })
  209. this.handler.promises
  210. .uploadFileFromDisk(this.projectId, this.fileArgs, this.fsPath)
  211. .catch(reject)
  212. })
  213. })
  214. it('should pass the correct options to request', async function () {
  215. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  216. this.fs.createReadStream.returns({
  217. pipe: sinon.stub(),
  218. on: sinon.stub((type, cb) => {
  219. if (type === 'open') {
  220. cb()
  221. }
  222. }),
  223. })
  224. await this.handler.promises.uploadFileFromDisk(
  225. this.projectId,
  226. this.fileArgs,
  227. this.fsPath
  228. )
  229. this.request.args[0][0].method.should.equal('post')
  230. this.request.args[0][0].uri.should.equal(fileUrl)
  231. })
  232. it('should resolve with the url and fileRef', async function () {
  233. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  234. this.fs.createReadStream.returns({
  235. pipe: sinon.stub(),
  236. on: sinon.stub((type, cb) => {
  237. if (type === 'open') {
  238. cb()
  239. }
  240. }),
  241. })
  242. const { url, fileRef } = await this.handler.promises.uploadFileFromDisk(
  243. this.projectId,
  244. this.fileArgs,
  245. this.fsPath
  246. )
  247. expect(url).to.equal(fileUrl)
  248. expect(fileRef._id).to.equal(this.fileId)
  249. expect(fileRef.hash).to.equal(this.hashValue)
  250. })
  251. describe('when upload to filestore fails', function () {
  252. beforeEach(function () {
  253. this.writeStream.on = function (type, fn) {
  254. if (type === 'response') {
  255. fn({ statusCode: 500 })
  256. }
  257. }
  258. })
  259. it('should reject with an error', async function () {
  260. this.fs.createReadStream.callCount = 0
  261. this.fs.createReadStream.returns({
  262. pipe: sinon.stub(),
  263. on: sinon.stub((type, cb) => {
  264. if (type === 'open') {
  265. cb()
  266. }
  267. }),
  268. })
  269. let error
  270. try {
  271. await this.handler.promises.uploadFileFromDisk(
  272. this.projectId,
  273. this.fileArgs,
  274. this.fsPath
  275. )
  276. } catch (err) {
  277. error = err
  278. }
  279. expect(error).to.be.instanceOf(Error)
  280. expect(this.fs.createReadStream.callCount).to.equal(
  281. this.handler.RETRY_ATTEMPTS
  282. )
  283. })
  284. })
  285. })
  286. describe('when filestore feature is disabled', function () {
  287. beforeEach(function () {
  288. this.Features.hasFeature.withArgs('filestore').returns(false)
  289. })
  290. it('should not open file handle', async function () {
  291. await this.handler.promises.uploadFileFromDisk(
  292. this.projectId,
  293. this.fileArgs,
  294. this.fsPath
  295. )
  296. expect(this.fs.createReadStream).to.not.have.been.called
  297. })
  298. it('should not talk to filestore', async function () {
  299. await this.handler.promises.uploadFileFromDisk(
  300. this.projectId,
  301. this.fileArgs,
  302. this.fsPath
  303. )
  304. expect(this.request).to.not.have.been.called
  305. })
  306. it('should resolve with the url and fileRef', async function () {
  307. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  308. const { url, fileRef } = await this.handler.promises.uploadFileFromDisk(
  309. this.projectId,
  310. this.fileArgs,
  311. this.fsPath
  312. )
  313. expect(url).to.equal(fileUrl)
  314. expect(fileRef._id).to.equal(this.fileId)
  315. expect(fileRef.hash).to.equal(this.hashValue)
  316. })
  317. })
  318. describe('symlink', function () {
  319. it('should not read file if it is symlink', async function () {
  320. this.fs.lstat = sinon.stub().callsArgWith(1, null, {
  321. isFile() {
  322. return false
  323. },
  324. isDirectory() {
  325. return false
  326. },
  327. })
  328. let error
  329. try {
  330. await this.handler.promises.uploadFileFromDisk(
  331. this.projectId,
  332. this.fileArgs,
  333. this.fsPath
  334. )
  335. } catch (err) {
  336. error = err
  337. }
  338. expect(error).to.exist
  339. this.fs.createReadStream.called.should.equal(false)
  340. })
  341. it('should not read file stat returns nothing', async function () {
  342. this.fs.lstat = sinon.stub().callsArgWith(1, null, null)
  343. let error
  344. try {
  345. await this.handler.promises.uploadFileFromDisk(
  346. this.projectId,
  347. this.fileArgs,
  348. this.fsPath
  349. )
  350. } catch (err) {
  351. error = err
  352. }
  353. expect(error).to.exist
  354. this.fs.createReadStream.called.should.equal(false)
  355. })
  356. })
  357. })
  358. describe('deleteFile', function () {
  359. it('should send a delete request to filestore api', function (done) {
  360. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  361. this.request.callsArgWith(1, null)
  362. this.handler.deleteFile(this.projectId, this.fileId, err => {
  363. assert.equal(err, undefined)
  364. this.request.args[0][0].method.should.equal('delete')
  365. this.request.args[0][0].uri.should.equal(fileUrl)
  366. done()
  367. })
  368. })
  369. it('should reject with the error if there is one', async function () {
  370. const expectedError = 'my error'
  371. this.request.callsArgWith(1, expectedError)
  372. let error
  373. try {
  374. await this.handler.promises.deleteFile(this.projectId, this.fileId)
  375. } catch (err) {
  376. error = err
  377. }
  378. expect(error).to.equal(expectedError)
  379. })
  380. })
  381. describe('deleteProject', function () {
  382. describe('when filestore is enabled', function () {
  383. beforeEach(function () {
  384. this.Features.hasFeature.withArgs('filestore').returns(true)
  385. })
  386. it('should send a delete request to filestore api', async function () {
  387. const projectUrl = this.getProjectUrl(this.projectId)
  388. this.request.callsArgWith(1, null)
  389. await this.handler.promises.deleteProject(this.projectId)
  390. this.request.args[0][0].method.should.equal('delete')
  391. this.request.args[0][0].uri.should.equal(projectUrl)
  392. })
  393. it('should wrap the error if there is one', async function () {
  394. const expectedError = new Error('my error')
  395. this.request.callsArgWith(1, expectedError)
  396. const promise = this.handler.promises.deleteProject(this.projectId)
  397. let error
  398. try {
  399. await promise
  400. } catch (err) {
  401. error = err
  402. }
  403. expect(error).to.exist
  404. expect(OError.getFullStack(error)).to.match(
  405. /something went wrong deleting a project in filestore/
  406. )
  407. expect(OError.getFullStack(error)).to.match(/my error/)
  408. })
  409. })
  410. describe('when filestore is disabled', function () {
  411. beforeEach(function () {
  412. this.Features.hasFeature.withArgs('filestore').returns(false)
  413. })
  414. it('should not send a delete request to filestore api', async function () {
  415. await this.handler.promises.deleteProject(this.projectId)
  416. this.request.called.should.equal(false)
  417. })
  418. })
  419. })
  420. describe('getFileStream', function () {
  421. describe('when filestore is disabled', function () {
  422. beforeEach(function () {
  423. this.Features.hasFeature.withArgs('filestore').returns(false)
  424. })
  425. it('should callback with a NotFoundError', async function () {
  426. let error
  427. try {
  428. await this.handler.promises.getFileStream(
  429. this.projectId,
  430. this.fileId,
  431. {}
  432. )
  433. } catch (err) {
  434. error = err
  435. }
  436. expect(error).to.be.instanceOf(Errors.NotFoundError)
  437. })
  438. it('should not call request', async function () {
  439. let error
  440. try {
  441. await this.handler.promises.getFileStream(
  442. this.projectId,
  443. this.fileId,
  444. {}
  445. )
  446. } catch (err) {
  447. error = err
  448. }
  449. expect(error).to.exist
  450. this.request.called.should.equal(false)
  451. })
  452. })
  453. describe('when filestore is enabled', function () {
  454. beforeEach(function () {
  455. this.query = {}
  456. this.request.returns(this.readStream)
  457. this.Features.hasFeature.withArgs('filestore').returns(true)
  458. })
  459. it('should get the stream with the correct params', async function () {
  460. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  461. await this.handler.promises.getFileStream(
  462. this.projectId,
  463. this.fileId,
  464. this.query
  465. )
  466. this.request.args[0][0].method.should.equal('get')
  467. this.request.args[0][0].uri.should.equal(
  468. fileUrl + '?from=getFileStream'
  469. )
  470. })
  471. it('should get stream from request', async function () {
  472. const stream = await this.handler.promises.getFileStream(
  473. this.projectId,
  474. this.fileId,
  475. this.query
  476. )
  477. stream.should.equal(this.readStream)
  478. })
  479. it('should add an error handler', async function () {
  480. const stream = await this.handler.promises.getFileStream(
  481. this.projectId,
  482. this.fileId,
  483. this.query
  484. )
  485. stream.on.calledWith('error').should.equal(true)
  486. })
  487. describe('when range is specified in query', function () {
  488. beforeEach(function () {
  489. this.query = { range: '0-10' }
  490. })
  491. it('should add a range header', async function () {
  492. await this.handler.promises.getFileStream(
  493. this.projectId,
  494. this.fileId,
  495. this.query
  496. )
  497. this.request.callCount.should.equal(1)
  498. const { headers } = this.request.firstCall.args[0]
  499. expect(headers).to.have.keys('range')
  500. expect(headers.range).to.equal('bytes=0-10')
  501. })
  502. describe('when range is invalid', function () {
  503. ;['0-', '-100', 'one-two', 'nonsense'].forEach(r => {
  504. beforeEach(function () {
  505. this.query = { range: `${r}` }
  506. })
  507. it(`should not add a range header for '${r}'`, async function () {
  508. await this.handler.promises.getFileStream(
  509. this.projectId,
  510. this.fileId,
  511. this.query
  512. )
  513. this.request.callCount.should.equal(1)
  514. const { headers } = this.request.firstCall.args[0]
  515. expect(headers).to.not.have.keys('range')
  516. })
  517. })
  518. })
  519. })
  520. })
  521. })
  522. describe('getFileSize', function () {
  523. it('returns the file size reported by filestore', async function () {
  524. const expectedFileSize = 32432
  525. const fileUrl =
  526. this.getFileUrl(this.projectId, this.fileId) + '?from=getFileSize'
  527. this.request.head.yields(
  528. new Error('request.head() received unexpected arguments')
  529. )
  530. this.request.head.withArgs(fileUrl).yields(null, {
  531. statusCode: 200,
  532. headers: {
  533. 'content-length': expectedFileSize,
  534. },
  535. })
  536. const fileSize = await this.handler.promises.getFileSize(
  537. this.projectId,
  538. this.fileId
  539. )
  540. expect(fileSize).to.equal(expectedFileSize)
  541. })
  542. it('throws a NotFoundError on a 404 from filestore', async function () {
  543. this.request.head.yields(null, { statusCode: 404 })
  544. let error
  545. try {
  546. await this.handler.promises.getFileSize(this.projectId, this.fileId)
  547. } catch (err) {
  548. error = err
  549. }
  550. expect(error).to.be.instanceOf(Errors.NotFoundError)
  551. })
  552. it('throws an error on a non-200 from filestore', async function () {
  553. this.request.head.yields(null, { statusCode: 500 })
  554. let error
  555. try {
  556. await this.handler.promises.getFileSize(this.projectId, this.fileId)
  557. } catch (err) {
  558. error = err
  559. }
  560. expect(error).to.be.instanceOf(Error)
  561. })
  562. it('rethrows errors from filestore', async function () {
  563. const expectedError = new Error('from filestore')
  564. this.request.head.yields(expectedError)
  565. let error
  566. try {
  567. await this.handler.promises.getFileSize(this.projectId, this.fileId)
  568. } catch (err) {
  569. error = err
  570. }
  571. expect(error).to.equal(expectedError)
  572. })
  573. })
  574. describe('copyFile', function () {
  575. beforeEach(function () {
  576. this.newProjectId = 'new project'
  577. this.newFileId = 'new file id'
  578. })
  579. it('should post json', async function () {
  580. const newFileUrl = this.getFileUrl(this.newProjectId, this.newFileId)
  581. this.request.callsArgWith(1, null, { statusCode: 200 })
  582. await this.handler.promises.copyFile(
  583. this.projectId,
  584. this.fileId,
  585. this.newProjectId,
  586. this.newFileId
  587. )
  588. this.request.args[0][0].method.should.equal('put')
  589. this.request.args[0][0].uri.should.equal(newFileUrl)
  590. this.request.args[0][0].json.source.project_id.should.equal(
  591. this.projectId
  592. )
  593. this.request.args[0][0].json.source.file_id.should.equal(this.fileId)
  594. })
  595. it('returns the url', async function () {
  596. const expectedUrl = this.getFileUrl(this.newProjectId, this.newFileId)
  597. this.request.callsArgWith(1, null, { statusCode: 200 })
  598. const url = await this.handler.promises.copyFile(
  599. this.projectId,
  600. this.fileId,
  601. this.newProjectId,
  602. this.newFileId
  603. )
  604. url.should.equal(expectedUrl)
  605. })
  606. it('should return the err', async function () {
  607. const expectedError = new Error('error')
  608. this.request.callsArgWith(1, expectedError)
  609. let error
  610. try {
  611. await this.handler.promises.copyFile(
  612. this.projectId,
  613. this.fileId,
  614. this.newProjectId,
  615. this.newFileId
  616. )
  617. } catch (err) {
  618. error = err
  619. }
  620. expect(error).to.equal(expectedError)
  621. })
  622. it('should return an error for a non-success statusCode', async function () {
  623. this.request.callsArgWith(1, null, { statusCode: 500 })
  624. let error
  625. try {
  626. await this.handler.promises.copyFile(
  627. this.projectId,
  628. this.fileId,
  629. this.newProjectId,
  630. this.newFileId
  631. )
  632. } catch (err) {
  633. error = err
  634. }
  635. expect(error).to.be.instanceOf(Error)
  636. expect(error).to.have.property(
  637. 'message',
  638. 'non-ok response from filestore for copyFile: 500'
  639. )
  640. })
  641. })
  642. })