FSPersistorTests.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. const crypto = require('crypto')
  2. const { expect } = require('chai')
  3. const mockFs = require('mock-fs')
  4. const fs = require('fs')
  5. const fsPromises = require('fs/promises')
  6. const Path = require('path')
  7. const StreamPromises = require('stream/promises')
  8. const SandboxedModule = require('sandboxed-module')
  9. const Errors = require('../../src/Errors')
  10. const MODULE_PATH = '../../src/FSPersistor.js'
  11. describe('FSPersistorTests', function () {
  12. const localFiles = {
  13. '/uploads/info.txt': Buffer.from('This information is critical', {
  14. encoding: 'utf-8',
  15. }),
  16. '/uploads/other.txt': Buffer.from('Some other content', {
  17. encoding: 'utf-8',
  18. }),
  19. }
  20. const location = '/bucket'
  21. const files = {
  22. wombat: 'animals/wombat.tex',
  23. giraffe: 'animals/giraffe.tex',
  24. potato: 'vegetables/potato.tex',
  25. }
  26. const scenarios = [
  27. {
  28. description: 'default settings',
  29. settings: {},
  30. fsPath: key => Path.join(location, key.replaceAll('/', '_')),
  31. },
  32. {
  33. description: 'with useSubdirectories = true',
  34. settings: { useSubdirectories: true },
  35. fsPath: key => Path.join(location, key),
  36. },
  37. ]
  38. for (const scenario of scenarios) {
  39. describe(scenario.description, function () {
  40. let persistor
  41. beforeEach(function () {
  42. const FSPersistor = SandboxedModule.require(MODULE_PATH, {
  43. requires: {
  44. 'fs/promises': fsPromises,
  45. 'stream/promises': StreamPromises,
  46. './Errors': Errors,
  47. },
  48. })
  49. persistor = new FSPersistor(scenario.settings)
  50. })
  51. beforeEach(function () {
  52. mockFs({
  53. ...localFiles,
  54. '/not-a-dir':
  55. 'This regular file is meant to prevent using this path as a directory',
  56. '/directory/subdirectory': {},
  57. })
  58. })
  59. afterEach(function () {
  60. mockFs.restore()
  61. })
  62. describe('sendFile', function () {
  63. it('should copy the file', async function () {
  64. await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
  65. const contents = await fsPromises.readFile(
  66. scenario.fsPath(files.wombat)
  67. )
  68. expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
  69. })
  70. it('should return an error if the file cannot be stored', async function () {
  71. await expect(
  72. persistor.sendFile('/not-a-dir', files.wombat, '/uploads/info.txt')
  73. ).to.be.rejectedWith(Errors.WriteError)
  74. })
  75. })
  76. describe('sendStream', function () {
  77. let stream
  78. describe("when the file doesn't exist", function () {
  79. beforeEach(function () {
  80. stream = fs.createReadStream('/uploads/info.txt')
  81. })
  82. it('should write the stream to disk', async function () {
  83. await persistor.sendStream(location, files.wombat, stream)
  84. const contents = await fsPromises.readFile(
  85. scenario.fsPath(files.wombat)
  86. )
  87. expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
  88. })
  89. it('should delete the temporary file', async function () {
  90. await persistor.sendStream(location, files.wombat, stream)
  91. const entries = await fsPromises.readdir(location)
  92. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  93. expect(tempDirs).to.be.empty
  94. })
  95. describe('on error', function () {
  96. beforeEach(async function () {
  97. await expect(
  98. persistor.sendStream('/not-a-dir', files.wombat, stream)
  99. ).to.be.rejectedWith(Errors.WriteError)
  100. })
  101. it('should not write the target file', async function () {
  102. await expect(
  103. fsPromises.access(scenario.fsPath(files.wombat))
  104. ).to.be.rejected
  105. })
  106. it('should delete the temporary file', async function () {
  107. await persistor.sendStream(location, files.wombat, stream)
  108. const entries = await fsPromises.readdir(location)
  109. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  110. expect(tempDirs).to.be.empty
  111. })
  112. })
  113. describe('when the md5 hash matches', function () {
  114. it('should write the stream to disk', async function () {
  115. await persistor.sendStream(location, files.wombat, stream, {
  116. sourceMd5: md5(localFiles['/uploads/info.txt']),
  117. })
  118. const contents = await fsPromises.readFile(
  119. scenario.fsPath(files.wombat)
  120. )
  121. expect(
  122. contents.equals(localFiles['/uploads/info.txt'])
  123. ).to.be.true
  124. })
  125. })
  126. describe('when the md5 hash does not match', function () {
  127. beforeEach(async function () {
  128. await expect(
  129. persistor.sendStream(location, files.wombat, stream, {
  130. sourceMd5: md5('wrong content'),
  131. })
  132. ).to.be.rejectedWith(Errors.WriteError)
  133. })
  134. it('should not write the target file', async function () {
  135. await expect(
  136. fsPromises.access(scenario.fsPath(files.wombat))
  137. ).to.be.rejected
  138. })
  139. it('should delete the temporary file', async function () {
  140. await persistor.sendStream(location, files.wombat, stream)
  141. const entries = await fsPromises.readdir(location)
  142. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  143. expect(tempDirs).to.be.empty
  144. })
  145. })
  146. })
  147. describe('when the file already exists', function () {
  148. let stream
  149. beforeEach(async function () {
  150. await persistor.sendFile(
  151. location,
  152. files.wombat,
  153. '/uploads/info.txt'
  154. )
  155. stream = fs.createReadStream('/uploads/other.txt')
  156. })
  157. it('should write the stream to disk', async function () {
  158. await persistor.sendStream(location, files.wombat, stream)
  159. const contents = await fsPromises.readFile(
  160. scenario.fsPath(files.wombat)
  161. )
  162. expect(contents.equals(localFiles['/uploads/other.txt'])).to.be.true
  163. })
  164. it('should delete the temporary file', async function () {
  165. await persistor.sendStream(location, files.wombat, stream)
  166. const entries = await fsPromises.readdir(location)
  167. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  168. expect(tempDirs).to.be.empty
  169. })
  170. describe('on error', function () {
  171. beforeEach(async function () {
  172. await expect(
  173. persistor.sendStream('/not-a-dir', files.wombat, stream)
  174. ).to.be.rejectedWith(Errors.WriteError)
  175. })
  176. it('should not update the target file', async function () {
  177. const contents = await fsPromises.readFile(
  178. scenario.fsPath(files.wombat)
  179. )
  180. expect(
  181. contents.equals(localFiles['/uploads/info.txt'])
  182. ).to.be.true
  183. })
  184. it('should delete the temporary file', async function () {
  185. await persistor.sendStream(location, files.wombat, stream)
  186. const entries = await fsPromises.readdir(location)
  187. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  188. expect(tempDirs).to.be.empty
  189. })
  190. })
  191. describe('when the md5 hash matches', function () {
  192. it('should write the stream to disk', async function () {
  193. await persistor.sendStream(location, files.wombat, stream, {
  194. sourceMd5: md5(localFiles['/uploads/other.txt']),
  195. })
  196. const contents = await fsPromises.readFile(
  197. scenario.fsPath(files.wombat)
  198. )
  199. expect(
  200. contents.equals(localFiles['/uploads/other.txt'])
  201. ).to.be.true
  202. })
  203. })
  204. describe('when the md5 hash does not match', function () {
  205. beforeEach(async function () {
  206. await expect(
  207. persistor.sendStream(location, files.wombat, stream, {
  208. sourceMd5: md5('wrong content'),
  209. })
  210. ).to.be.rejectedWith(Errors.WriteError)
  211. })
  212. it('should not update the target file', async function () {
  213. const contents = await fsPromises.readFile(
  214. scenario.fsPath(files.wombat)
  215. )
  216. expect(
  217. contents.equals(localFiles['/uploads/info.txt'])
  218. ).to.be.true
  219. })
  220. it('should delete the temporary file', async function () {
  221. await persistor.sendStream(location, files.wombat, stream)
  222. const entries = await fsPromises.readdir(location)
  223. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  224. expect(tempDirs).to.be.empty
  225. })
  226. })
  227. })
  228. })
  229. describe('getObjectStream', function () {
  230. beforeEach(async function () {
  231. await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
  232. })
  233. it('should return a string with the object contents', async function () {
  234. const stream = await persistor.getObjectStream(location, files.wombat)
  235. const contents = await streamToBuffer(stream)
  236. expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
  237. })
  238. it('should support ranges', async function () {
  239. const stream = await persistor.getObjectStream(
  240. location,
  241. files.wombat,
  242. {
  243. start: 5,
  244. end: 16,
  245. }
  246. )
  247. const contents = await streamToBuffer(stream)
  248. // end is inclusive in ranges, but exclusive in slice()
  249. expect(
  250. contents.equals(localFiles['/uploads/info.txt'].slice(5, 17))
  251. ).to.be.true
  252. })
  253. it('should give a NotFoundError if the file does not exist', async function () {
  254. await expect(
  255. persistor.getObjectStream(location, 'does-not-exist')
  256. ).to.be.rejectedWith(Errors.NotFoundError)
  257. })
  258. })
  259. describe('getObjectSize', function () {
  260. beforeEach(async function () {
  261. await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
  262. })
  263. it('should return the file size', async function () {
  264. expect(
  265. await persistor.getObjectSize(location, files.wombat)
  266. ).to.equal(localFiles['/uploads/info.txt'].length)
  267. })
  268. it('should throw a NotFoundError if the file does not exist', async function () {
  269. await expect(
  270. persistor.getObjectSize(location, 'does-not-exist')
  271. ).to.be.rejectedWith(Errors.NotFoundError)
  272. })
  273. })
  274. describe('copyObject', function () {
  275. beforeEach(async function () {
  276. await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
  277. })
  278. it('Should copy the file to the new location', async function () {
  279. await persistor.copyObject(location, files.wombat, files.potato)
  280. const contents = await fsPromises.readFile(
  281. scenario.fsPath(files.potato)
  282. )
  283. expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
  284. })
  285. })
  286. describe('deleteObject', function () {
  287. beforeEach(async function () {
  288. await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
  289. await fsPromises.access(scenario.fsPath(files.wombat))
  290. })
  291. it('should delete the file', async function () {
  292. await persistor.deleteObject(location, files.wombat)
  293. await expect(
  294. fsPromises.access(scenario.fsPath(files.wombat))
  295. ).to.be.rejected
  296. })
  297. it("should ignore files that don't exist", async function () {
  298. await persistor.deleteObject(location, 'does-not-exist')
  299. })
  300. })
  301. describe('deleteDirectory', function () {
  302. beforeEach(async function () {
  303. for (const file of Object.values(files)) {
  304. await persistor.sendFile(location, file, '/uploads/info.txt')
  305. await fsPromises.access(scenario.fsPath(file))
  306. }
  307. })
  308. it('should delete all files under the directory', async function () {
  309. await persistor.deleteDirectory(location, 'animals')
  310. for (const file of [files.wombat, files.giraffe]) {
  311. await expect(fsPromises.access(scenario.fsPath(file))).to.be
  312. .rejected
  313. }
  314. })
  315. it('should not delete files under other directoris', async function () {
  316. await persistor.deleteDirectory(location, 'animals')
  317. await fsPromises.access(scenario.fsPath(files.potato))
  318. })
  319. it("should ignore directories that don't exist", async function () {
  320. await persistor.deleteDirectory(location, 'does-not-exist')
  321. for (const file of Object.values(files)) {
  322. await fsPromises.access(scenario.fsPath(file))
  323. }
  324. })
  325. })
  326. describe('checkIfObjectExists', function () {
  327. beforeEach(async function () {
  328. await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
  329. })
  330. it('should return true for existing files', async function () {
  331. expect(
  332. await persistor.checkIfObjectExists(location, files.wombat)
  333. ).to.equal(true)
  334. })
  335. it('should return false for non-existing files', async function () {
  336. expect(
  337. await persistor.checkIfObjectExists(location, 'does-not-exist')
  338. ).to.equal(false)
  339. })
  340. })
  341. describe('directorySize', function () {
  342. beforeEach(async function () {
  343. for (const file of Object.values(files)) {
  344. await persistor.sendFile(location, file, '/uploads/info.txt')
  345. }
  346. })
  347. it('should sum directory files size', async function () {
  348. expect(await persistor.directorySize(location, 'animals')).to.equal(
  349. 2 * localFiles['/uploads/info.txt'].length
  350. )
  351. })
  352. it('should return 0 on non-existing directories', async function () {
  353. expect(
  354. await persistor.directorySize(location, 'does-not-exist')
  355. ).to.equal(0)
  356. })
  357. })
  358. })
  359. }
  360. })
  361. function md5(str) {
  362. return crypto.createHash('md5').update(str).digest('hex')
  363. }
  364. async function streamToBuffer(stream) {
  365. const chunks = []
  366. for await (const chunk of stream) {
  367. chunks.push(chunk)
  368. }
  369. return Buffer.concat(chunks)
  370. }