FileStoreHandlerTests.js 14 KB

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