FileStoreHandlerTests.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  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.fs = {
  10. createReadStream: sinon.stub(),
  11. lstat: sinon.stub().callsArgWith(1, null, {
  12. isFile() {
  13. return true
  14. },
  15. isDirectory() {
  16. return false
  17. },
  18. size: this.fileSize,
  19. }),
  20. }
  21. this.writeStream = {
  22. my: 'writeStream',
  23. on(type, fn) {
  24. if (type === 'response') {
  25. fn({ statusCode: 200 })
  26. }
  27. },
  28. }
  29. this.readStream = { my: 'readStream', on: sinon.stub() }
  30. this.request = sinon.stub()
  31. this.request.head = sinon.stub()
  32. this.filestoreUrl = 'http://filestore.overleaf.test'
  33. this.settings = {
  34. apis: { filestore: { url: this.filestoreUrl } },
  35. }
  36. this.hashValue = '0123456789'
  37. this.fileArgs = { name: 'upload-filename' }
  38. this.fileId = 'file_id_here'
  39. this.projectId = '1312312312'
  40. this.historyId = 123
  41. this.fileSize = 999
  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.handler = SandboxedModule.require(MODULE_PATH, {
  70. requires: {
  71. '@overleaf/settings': this.settings,
  72. request: this.request,
  73. '../History/HistoryManager': this.HistoryManager,
  74. '../Project/ProjectDetailsHandler': this.ProjectDetailsHandler,
  75. './FileHashManager': this.FileHashManager,
  76. // FIXME: need to stub File object here
  77. '../../models/File': {
  78. File: this.FileModel,
  79. },
  80. fs: this.fs,
  81. },
  82. })
  83. })
  84. describe('uploadFileFromDisk', function () {
  85. beforeEach(function () {
  86. this.request.returns(this.writeStream)
  87. })
  88. it('should get the project details', function (done) {
  89. this.fs.createReadStream.returns({
  90. pipe() {},
  91. on(type, cb) {
  92. if (type === 'open') {
  93. cb()
  94. }
  95. },
  96. })
  97. this.handler.uploadFileFromDisk(
  98. this.projectId,
  99. this.fileArgs,
  100. this.fsPath,
  101. () => {
  102. this.ProjectDetailsHandler.getDetails
  103. .calledWith(this.projectId)
  104. .should.equal(true)
  105. done()
  106. }
  107. )
  108. })
  109. it('should compute the file hash', function (done) {
  110. this.fs.createReadStream.returns({
  111. pipe() {},
  112. on(type, cb) {
  113. if (type === 'open') {
  114. cb()
  115. }
  116. },
  117. })
  118. this.handler.uploadFileFromDisk(
  119. this.projectId,
  120. this.fileArgs,
  121. this.fsPath,
  122. () => {
  123. this.FileHashManager.computeHash
  124. .calledWith(this.fsPath)
  125. .should.equal(true)
  126. done()
  127. }
  128. )
  129. })
  130. it('should upload the file to the history store as a blob', function (done) {
  131. this.fs.createReadStream.returns({
  132. pipe() {},
  133. on(type, cb) {
  134. if (type === 'open') {
  135. cb()
  136. }
  137. },
  138. })
  139. this.handler.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. done()
  153. }
  154. )
  155. })
  156. it('should create read stream', function (done) {
  157. this.fs.createReadStream.returns({
  158. pipe() {},
  159. on(type, cb) {
  160. if (type === 'open') {
  161. cb()
  162. }
  163. },
  164. })
  165. this.handler.uploadFileFromDisk(
  166. this.projectId,
  167. this.fileArgs,
  168. this.fsPath,
  169. () => {
  170. this.fs.createReadStream.calledWith(this.fsPath).should.equal(true)
  171. done()
  172. }
  173. )
  174. })
  175. it('should pipe the read stream to request', function (done) {
  176. this.request.returns(this.writeStream)
  177. this.fs.createReadStream.returns({
  178. on(type, cb) {
  179. if (type === 'open') {
  180. cb()
  181. }
  182. },
  183. pipe: o => {
  184. this.writeStream.should.equal(o)
  185. done()
  186. },
  187. })
  188. this.handler.uploadFileFromDisk(
  189. this.projectId,
  190. this.fileArgs,
  191. this.fsPath,
  192. () => {}
  193. )
  194. })
  195. it('should pass the correct options to request', function (done) {
  196. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  197. this.fs.createReadStream.returns({
  198. pipe() {},
  199. on(type, cb) {
  200. if (type === 'open') {
  201. cb()
  202. }
  203. },
  204. })
  205. this.handler.uploadFileFromDisk(
  206. this.projectId,
  207. this.fileArgs,
  208. this.fsPath,
  209. () => {
  210. this.request.args[0][0].method.should.equal('post')
  211. this.request.args[0][0].uri.should.equal(fileUrl)
  212. done()
  213. }
  214. )
  215. })
  216. it('should callback with the url and fileRef', function (done) {
  217. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  218. this.fs.createReadStream.returns({
  219. pipe() {},
  220. on(type, cb) {
  221. if (type === 'open') {
  222. cb()
  223. }
  224. },
  225. })
  226. this.handler.uploadFileFromDisk(
  227. this.projectId,
  228. this.fileArgs,
  229. this.fsPath,
  230. (err, url, fileRef) => {
  231. expect(err).to.not.exist
  232. expect(url).to.equal(fileUrl)
  233. expect(fileRef._id).to.equal(this.fileId)
  234. expect(fileRef.hash).to.equal(this.hashValue)
  235. done()
  236. }
  237. )
  238. })
  239. describe('symlink', function () {
  240. it('should not read file if it is symlink', function (done) {
  241. this.fs.lstat = sinon.stub().callsArgWith(1, null, {
  242. isFile() {
  243. return false
  244. },
  245. isDirectory() {
  246. return false
  247. },
  248. })
  249. this.handler.uploadFileFromDisk(
  250. this.projectId,
  251. this.fileArgs,
  252. this.fsPath,
  253. () => {
  254. this.fs.createReadStream.called.should.equal(false)
  255. done()
  256. }
  257. )
  258. })
  259. it('should not read file stat returns nothing', function (done) {
  260. this.fs.lstat = sinon.stub().callsArgWith(1, null, null)
  261. this.handler.uploadFileFromDisk(
  262. this.projectId,
  263. this.fileArgs,
  264. this.fsPath,
  265. () => {
  266. this.fs.createReadStream.called.should.equal(false)
  267. done()
  268. }
  269. )
  270. })
  271. })
  272. describe('when upload fails', function () {
  273. beforeEach(function () {
  274. this.writeStream.on = function (type, fn) {
  275. if (type === 'response') {
  276. fn({ statusCode: 500 })
  277. }
  278. }
  279. })
  280. it('should callback with an error', function (done) {
  281. this.fs.createReadStream.callCount = 0
  282. this.fs.createReadStream.returns({
  283. pipe() {},
  284. on(type, cb) {
  285. if (type === 'open') {
  286. cb()
  287. }
  288. },
  289. })
  290. this.handler.uploadFileFromDisk(
  291. this.projectId,
  292. this.fileArgs,
  293. this.fsPath,
  294. err => {
  295. expect(err).to.exist
  296. expect(err).to.be.instanceof(Error)
  297. expect(this.fs.createReadStream.callCount).to.equal(
  298. this.handler.RETRY_ATTEMPTS
  299. )
  300. done()
  301. }
  302. )
  303. })
  304. })
  305. })
  306. describe('deleteFile', function () {
  307. it('should send a delete request to filestore api', function (done) {
  308. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  309. this.request.callsArgWith(1, null)
  310. this.handler.deleteFile(this.projectId, this.fileId, err => {
  311. assert.equal(err, undefined)
  312. this.request.args[0][0].method.should.equal('delete')
  313. this.request.args[0][0].uri.should.equal(fileUrl)
  314. done()
  315. })
  316. })
  317. it('should return the error if there is one', function (done) {
  318. const error = 'my error'
  319. this.request.callsArgWith(1, error)
  320. this.handler.deleteFile(this.projectId, this.fileId, err => {
  321. assert.equal(err, error)
  322. done()
  323. })
  324. })
  325. })
  326. describe('deleteProject', function () {
  327. it('should send a delete request to filestore api', function (done) {
  328. const projectUrl = this.getProjectUrl(this.projectId)
  329. this.request.callsArgWith(1, null)
  330. this.handler.deleteProject(this.projectId, err => {
  331. assert.equal(err, undefined)
  332. this.request.args[0][0].method.should.equal('delete')
  333. this.request.args[0][0].uri.should.equal(projectUrl)
  334. done()
  335. })
  336. })
  337. it('should wrap the error if there is one', function (done) {
  338. const error = new Error('my error')
  339. this.request.callsArgWith(1, error)
  340. this.handler.deleteProject(this.projectId, err => {
  341. expect(OError.getFullStack(err)).to.match(
  342. /something went wrong deleting a project in filestore/
  343. )
  344. expect(OError.getFullStack(err)).to.match(/my error/)
  345. done()
  346. })
  347. })
  348. })
  349. describe('getFileStream', function () {
  350. beforeEach(function () {
  351. this.query = {}
  352. this.request.returns(this.readStream)
  353. })
  354. it('should get the stream with the correct params', function (done) {
  355. const fileUrl = this.getFileUrl(this.projectId, this.fileId)
  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.args[0][0].method.should.equal('get')
  365. this.request.args[0][0].uri.should.equal(
  366. fileUrl + '?from=getFileStream'
  367. )
  368. done()
  369. }
  370. )
  371. })
  372. it('should get stream from request', function (done) {
  373. this.handler.getFileStream(
  374. this.projectId,
  375. this.fileId,
  376. this.query,
  377. (err, stream) => {
  378. if (err) {
  379. return done(err)
  380. }
  381. stream.should.equal(this.readStream)
  382. done()
  383. }
  384. )
  385. })
  386. it('should add an error handler', function (done) {
  387. this.handler.getFileStream(
  388. this.projectId,
  389. this.fileId,
  390. this.query,
  391. (err, stream) => {
  392. if (err) {
  393. return done(err)
  394. }
  395. stream.on.calledWith('error').should.equal(true)
  396. done()
  397. }
  398. )
  399. })
  400. describe('when range is specified in query', function () {
  401. beforeEach(function () {
  402. this.query = { range: '0-10' }
  403. })
  404. it('should add a range header', function (done) {
  405. this.handler.getFileStream(
  406. this.projectId,
  407. this.fileId,
  408. this.query,
  409. (err, stream) => {
  410. if (err) {
  411. return done(err)
  412. }
  413. this.request.callCount.should.equal(1)
  414. const { headers } = this.request.firstCall.args[0]
  415. expect(headers).to.have.keys('range')
  416. expect(headers.range).to.equal('bytes=0-10')
  417. done()
  418. }
  419. )
  420. })
  421. describe('when range is invalid', function () {
  422. ;['0-', '-100', 'one-two', 'nonsense'].forEach(r => {
  423. beforeEach(function () {
  424. this.query = { range: `${r}` }
  425. })
  426. it(`should not add a range header for '${r}'`, function (done) {
  427. this.handler.getFileStream(
  428. this.projectId,
  429. this.fileId,
  430. this.query,
  431. (err, stream) => {
  432. if (err) {
  433. return done(err)
  434. }
  435. this.request.callCount.should.equal(1)
  436. const { headers } = this.request.firstCall.args[0]
  437. expect(headers).to.not.have.keys('range')
  438. done()
  439. }
  440. )
  441. })
  442. })
  443. })
  444. })
  445. })
  446. describe('getFileSize', function () {
  447. it('returns the file size reported by filestore', function (done) {
  448. const expectedFileSize = 32432
  449. const fileUrl =
  450. this.getFileUrl(this.projectId, this.fileId) + '?from=getFileSize'
  451. this.request.head.yields(
  452. new Error('request.head() received unexpected arguments')
  453. )
  454. this.request.head.withArgs(fileUrl).yields(null, {
  455. statusCode: 200,
  456. headers: {
  457. 'content-length': expectedFileSize,
  458. },
  459. })
  460. this.handler.getFileSize(this.projectId, this.fileId, (err, fileSize) => {
  461. if (err) {
  462. return done(err)
  463. }
  464. expect(fileSize).to.equal(expectedFileSize)
  465. done()
  466. })
  467. })
  468. it('throws a NotFoundError on a 404 from filestore', function (done) {
  469. this.request.head.yields(null, { statusCode: 404 })
  470. this.handler.getFileSize(this.projectId, this.fileId, err => {
  471. expect(err).to.be.instanceof(Errors.NotFoundError)
  472. done()
  473. })
  474. })
  475. it('throws an error on a non-200 from filestore', function (done) {
  476. this.request.head.yields(null, { statusCode: 500 })
  477. this.handler.getFileSize(this.projectId, this.fileId, err => {
  478. expect(err).to.be.instanceof(Error)
  479. done()
  480. })
  481. })
  482. it('rethrows errors from filestore', function (done) {
  483. this.request.head.yields(new Error())
  484. this.handler.getFileSize(this.projectId, this.fileId, err => {
  485. expect(err).to.be.instanceof(Error)
  486. done()
  487. })
  488. })
  489. })
  490. describe('copyFile', function () {
  491. beforeEach(function () {
  492. this.newProjectId = 'new project'
  493. this.newFileId = 'new file id'
  494. })
  495. it('should post json', function (done) {
  496. const newFileUrl = this.getFileUrl(this.newProjectId, this.newFileId)
  497. this.request.callsArgWith(1, null, { statusCode: 200 })
  498. this.handler.copyFile(
  499. this.projectId,
  500. this.fileId,
  501. this.newProjectId,
  502. this.newFileId,
  503. () => {
  504. this.request.args[0][0].method.should.equal('put')
  505. this.request.args[0][0].uri.should.equal(newFileUrl)
  506. this.request.args[0][0].json.source.project_id.should.equal(
  507. this.projectId
  508. )
  509. this.request.args[0][0].json.source.file_id.should.equal(this.fileId)
  510. done()
  511. }
  512. )
  513. })
  514. it('returns the url', function (done) {
  515. const expectedUrl = this.getFileUrl(this.newProjectId, this.newFileId)
  516. this.request.callsArgWith(1, null, { statusCode: 200 })
  517. this.handler.copyFile(
  518. this.projectId,
  519. this.fileId,
  520. this.newProjectId,
  521. this.newFileId,
  522. (err, url) => {
  523. if (err) {
  524. return done(err)
  525. }
  526. url.should.equal(expectedUrl)
  527. done()
  528. }
  529. )
  530. })
  531. it('should return the err', function (done) {
  532. const error = new Error('error')
  533. this.request.callsArgWith(1, error)
  534. this.handler.copyFile(
  535. this.projectId,
  536. this.fileId,
  537. this.newProjectId,
  538. this.newFileId,
  539. err => {
  540. err.should.equal(error)
  541. done()
  542. }
  543. )
  544. })
  545. it('should return an error for a non-success statusCode', function (done) {
  546. this.request.callsArgWith(1, null, { statusCode: 500 })
  547. this.handler.copyFile(
  548. this.projectId,
  549. this.fileId,
  550. this.newProjectId,
  551. this.newFileId,
  552. err => {
  553. err.should.be.an('error')
  554. err.message.should.equal(
  555. 'non-ok response from filestore for copyFile: 500'
  556. )
  557. done()
  558. }
  559. )
  560. })
  561. })
  562. })