FileStoreHandlerTests.js 13 KB

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