FSPersistorTests.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. const sinon = require('sinon')
  2. const chai = require('chai')
  3. const { expect } = chai
  4. const SandboxedModule = require('sandboxed-module')
  5. const Errors = require('../../src/Errors')
  6. const StreamModule = require('stream')
  7. const MODULE_PATH = '../../src/FSPersistor.js'
  8. describe('FSPersistorTests', function () {
  9. const stat = { size: 4, isFile: sinon.stub().returns(true) }
  10. const fd = 1234
  11. const writeStream = 'writeStream'
  12. const remoteStream = 'remoteStream'
  13. const location = '/foo'
  14. const error = new Error('guru meditation error')
  15. const md5 = 'ffffffff'
  16. const files = ['animals/wombat.tex', 'vegetables/potato.tex']
  17. const globs = [`${location}/${files[0]}`, `${location}/${files[1]}`]
  18. const filteredFilenames = ['animals_wombat.tex', 'vegetables_potato.tex']
  19. let fs,
  20. fsPromises,
  21. Stream,
  22. StreamPromises,
  23. FSPersistor,
  24. glob,
  25. readStream,
  26. crypto,
  27. Hash,
  28. uuid,
  29. tempFile
  30. beforeEach(function () {
  31. const randomNumber = Math.random().toString()
  32. readStream = {
  33. name: 'readStream',
  34. on: sinon.stub().yields(),
  35. pipe: sinon.stub(),
  36. }
  37. uuid = {
  38. v1: () => randomNumber,
  39. }
  40. tempFile = `/tmp/${randomNumber}`
  41. fs = {
  42. createReadStream: sinon.stub().returns(readStream),
  43. createWriteStream: sinon.stub().returns(writeStream),
  44. }
  45. fsPromises = {
  46. unlink: sinon.stub().resolves(),
  47. open: sinon.stub().resolves(fd),
  48. stat: sinon.stub().resolves(stat),
  49. }
  50. glob = sinon.stub().yields(null, globs)
  51. Stream = {
  52. Transform: StreamModule.Transform,
  53. }
  54. StreamPromises = {
  55. pipeline: sinon.stub().resolves(),
  56. }
  57. Hash = {
  58. end: sinon.stub(),
  59. read: sinon.stub().returns(md5),
  60. digest: sinon.stub().returns(md5),
  61. setEncoding: sinon.stub(),
  62. }
  63. crypto = {
  64. createHash: sinon.stub().returns(Hash),
  65. }
  66. FSPersistor = new (SandboxedModule.require(MODULE_PATH, {
  67. requires: {
  68. './Errors': Errors,
  69. fs,
  70. 'fs/promises': fsPromises,
  71. glob,
  72. stream: Stream,
  73. 'stream/promises': StreamPromises,
  74. crypto,
  75. 'node-uuid': uuid,
  76. // imported by PersistorHelper but otherwise unused here
  77. '@overleaf/logger': {},
  78. },
  79. globals: { console },
  80. }))({ paths: { uploadFolder: '/tmp' } })
  81. })
  82. describe('sendFile', function () {
  83. const localFilesystemPath = '/path/to/local/file'
  84. it('should copy the file', async function () {
  85. await FSPersistor.sendFile(location, files[0], localFilesystemPath)
  86. expect(fs.createReadStream).to.have.been.calledWith(localFilesystemPath)
  87. expect(fs.createWriteStream).to.have.been.calledWith(
  88. `${location}/${filteredFilenames[0]}`
  89. )
  90. expect(StreamPromises.pipeline).to.have.been.calledWith(
  91. readStream,
  92. writeStream
  93. )
  94. })
  95. it('should return an error if the file cannot be stored', async function () {
  96. StreamPromises.pipeline.rejects(error)
  97. await expect(
  98. FSPersistor.sendFile(location, files[0], localFilesystemPath)
  99. ).to.eventually.be.rejected.and.have.property('cause', error)
  100. })
  101. })
  102. describe('sendStream', function () {
  103. it('should write the stream to disk', async function () {
  104. await FSPersistor.sendStream(location, files[0], remoteStream)
  105. expect(StreamPromises.pipeline).to.have.been.calledWith(
  106. remoteStream,
  107. writeStream
  108. )
  109. })
  110. it('should delete the temporary file', async function () {
  111. await FSPersistor.sendStream(location, files[0], remoteStream)
  112. expect(fsPromises.unlink).to.have.been.calledWith(tempFile)
  113. })
  114. it('should wrap the error from the filesystem', async function () {
  115. StreamPromises.pipeline.rejects(error)
  116. await expect(FSPersistor.sendStream(location, files[0], remoteStream))
  117. .to.eventually.be.rejected.and.be.instanceOf(Errors.WriteError)
  118. .and.have.property('cause', error)
  119. })
  120. it('should send the temporary file to the filestore', async function () {
  121. await FSPersistor.sendStream(location, files[0], remoteStream)
  122. expect(fs.createReadStream).to.have.been.calledWith(tempFile)
  123. })
  124. describe('when the md5 hash does not match', function () {
  125. it('should return a write error', async function () {
  126. await expect(
  127. FSPersistor.sendStream(location, files[0], remoteStream, {
  128. sourceMd5: '00000000',
  129. })
  130. )
  131. .to.eventually.be.rejected.and.be.an.instanceOf(Errors.WriteError)
  132. .and.have.property('message', 'md5 hash mismatch')
  133. })
  134. it('deletes the copied file', async function () {
  135. try {
  136. await FSPersistor.sendStream(location, files[0], remoteStream, {
  137. sourceMd5: '00000000',
  138. })
  139. } catch (_) {}
  140. expect(fsPromises.unlink).to.have.been.calledWith(
  141. `${location}/${filteredFilenames[0]}`
  142. )
  143. })
  144. })
  145. })
  146. describe('getObjectStream', function () {
  147. it('should use correct file location', async function () {
  148. await FSPersistor.getObjectStream(location, files[0], {})
  149. expect(fsPromises.open).to.have.been.calledWith(
  150. `${location}/${filteredFilenames[0]}`
  151. )
  152. })
  153. it('should pass the options to createReadStream', async function () {
  154. await FSPersistor.getObjectStream(location, files[0], {
  155. start: 0,
  156. end: 8,
  157. })
  158. expect(fs.createReadStream).to.have.been.calledWith(null, {
  159. start: 0,
  160. end: 8,
  161. fd,
  162. })
  163. })
  164. it('should give a NotFoundError if the file does not exist', async function () {
  165. const err = new Error()
  166. err.code = 'ENOENT'
  167. fsPromises.open.rejects(err)
  168. await expect(FSPersistor.getObjectStream(location, files[0], {}))
  169. .to.eventually.be.rejected.and.be.an.instanceOf(Errors.NotFoundError)
  170. .and.have.property('cause', err)
  171. })
  172. it('should wrap any other error', async function () {
  173. fsPromises.open.rejects(error)
  174. await expect(FSPersistor.getObjectStream(location, files[0], {}))
  175. .to.eventually.be.rejectedWith('failed to open file for streaming')
  176. .and.be.an.instanceOf(Errors.ReadError)
  177. .and.have.property('cause', error)
  178. })
  179. })
  180. describe('getObjectSize', function () {
  181. const badFilename = 'neenaw.tex'
  182. const size = 65536
  183. const noentError = new Error('not found')
  184. noentError.code = 'ENOENT'
  185. beforeEach(function () {
  186. fsPromises.stat
  187. .rejects(error)
  188. .withArgs(`${location}/${filteredFilenames[0]}`)
  189. .resolves({ size })
  190. .withArgs(`${location}/${badFilename}`)
  191. .rejects(noentError)
  192. })
  193. it('should return the file size', async function () {
  194. expect(await FSPersistor.getObjectSize(location, files[0])).to.equal(size)
  195. })
  196. it('should throw a NotFoundError if the file does not exist', async function () {
  197. await expect(
  198. FSPersistor.getObjectSize(location, badFilename)
  199. ).to.eventually.be.rejected.and.be.an.instanceOf(Errors.NotFoundError)
  200. })
  201. it('should wrap any other error', async function () {
  202. await expect(FSPersistor.getObjectSize(location, 'raccoon'))
  203. .to.eventually.be.rejected.and.be.an.instanceOf(Errors.ReadError)
  204. .and.have.property('cause', error)
  205. })
  206. })
  207. describe('copyObject', function () {
  208. it('Should open the source for reading', async function () {
  209. await FSPersistor.copyObject(location, files[0], files[1])
  210. expect(fs.createReadStream).to.have.been.calledWith(
  211. `${location}/${filteredFilenames[0]}`
  212. )
  213. })
  214. it('Should open the target for writing', async function () {
  215. await FSPersistor.copyObject(location, files[0], files[1])
  216. expect(fs.createWriteStream).to.have.been.calledWith(
  217. `${location}/${filteredFilenames[1]}`
  218. )
  219. })
  220. it('Should pipe the source to the target', async function () {
  221. await FSPersistor.copyObject(location, files[0], files[1])
  222. expect(StreamPromises.pipeline).to.have.been.calledWith(
  223. readStream,
  224. writeStream
  225. )
  226. })
  227. })
  228. describe('deleteObject', function () {
  229. it('Should call unlink with correct options', async function () {
  230. await FSPersistor.deleteObject(location, files[0])
  231. expect(fsPromises.unlink).to.have.been.calledWith(
  232. `${location}/${filteredFilenames[0]}`
  233. )
  234. })
  235. it('Should propagate the error', async function () {
  236. fsPromises.unlink.rejects(error)
  237. await expect(
  238. FSPersistor.deleteObject(location, files[0])
  239. ).to.eventually.be.rejected.and.have.property('cause', error)
  240. })
  241. })
  242. describe('deleteDirectory', function () {
  243. it('Should call glob with correct options', async function () {
  244. await FSPersistor.deleteDirectory(location, files[0])
  245. expect(glob).to.have.been.calledWith(
  246. `${location}/${filteredFilenames[0]}_*`
  247. )
  248. })
  249. it('Should call unlink on the returned files', async function () {
  250. await FSPersistor.deleteDirectory(location, files[0])
  251. for (const filename of globs) {
  252. expect(fsPromises.unlink).to.have.been.calledWith(filename)
  253. }
  254. })
  255. it('Should propagate the error', async function () {
  256. glob.yields(error)
  257. await expect(
  258. FSPersistor.deleteDirectory(location, files[0])
  259. ).to.eventually.be.rejected.and.have.property('cause', error)
  260. })
  261. })
  262. describe('checkIfObjectExists', function () {
  263. const badFilename = 'pototo'
  264. const noentError = new Error('not found')
  265. noentError.code = 'ENOENT'
  266. beforeEach(function () {
  267. fsPromises.stat
  268. .rejects(error)
  269. .withArgs(`${location}/${filteredFilenames[0]}`)
  270. .resolves({})
  271. .withArgs(`${location}/${badFilename}`)
  272. .rejects(noentError)
  273. })
  274. it('Should call stat with correct options', async function () {
  275. await FSPersistor.checkIfObjectExists(location, files[0])
  276. expect(fsPromises.stat).to.have.been.calledWith(
  277. `${location}/${filteredFilenames[0]}`
  278. )
  279. })
  280. it('Should return true for existing files', async function () {
  281. expect(
  282. await FSPersistor.checkIfObjectExists(location, files[0])
  283. ).to.equal(true)
  284. })
  285. it('Should return false for non-existing files', async function () {
  286. expect(
  287. await FSPersistor.checkIfObjectExists(location, badFilename)
  288. ).to.equal(false)
  289. })
  290. it('should wrap the error if there is a problem', async function () {
  291. await expect(FSPersistor.checkIfObjectExists(location, 'llama'))
  292. .to.eventually.be.rejected.and.be.an.instanceOf(Errors.ReadError)
  293. .and.have.property('cause', error)
  294. })
  295. })
  296. describe('directorySize', function () {
  297. it('should wrap the error', async function () {
  298. glob.yields(error)
  299. await expect(FSPersistor.directorySize(location, files[0]))
  300. .to.eventually.be.rejected.and.be.an.instanceOf(Errors.ReadError)
  301. .and.include({ cause: error })
  302. .and.have.property('info')
  303. .which.includes({ location, name: files[0] })
  304. })
  305. it('should filter the directory name', async function () {
  306. await FSPersistor.directorySize(location, files[0])
  307. expect(glob).to.have.been.calledWith(
  308. `${location}/${filteredFilenames[0]}_*`
  309. )
  310. })
  311. it('should sum directory files size', async function () {
  312. expect(await FSPersistor.directorySize(location, files[0])).to.equal(
  313. stat.size * files.length
  314. )
  315. })
  316. })
  317. })