FileStoreHandlerTests.js 14 KB

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