FileStoreHandlerTests.js 21 KB

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