FSPersistorTests.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. const crypto = require('node:crypto')
  2. const os = require('node:os')
  3. const { expect } = require('chai')
  4. const fs = require('node:fs')
  5. const fsPromises = require('node:fs/promises')
  6. const Path = require('node:path')
  7. const StreamPromises = require('node:stream/promises')
  8. const SandboxedModule = require('sandboxed-module')
  9. const Errors = require('../../src/Errors')
  10. const MODULE_PATH = '../../src/FSPersistor.js'
  11. function createTree(base, tree) {
  12. fs.mkdirSync(base, { recursive: true })
  13. for (const [name, content] of Object.entries(tree)) {
  14. const fullPath = Path.join(base, name)
  15. if (Buffer.isBuffer(content) || typeof content === 'string') {
  16. fs.writeFileSync(fullPath, content)
  17. } else if (content && typeof content.symlink === 'string') {
  18. fs.symlinkSync(content.symlink, fullPath)
  19. } else {
  20. createTree(fullPath, content)
  21. }
  22. }
  23. }
  24. describe('FSPersistorTests', function () {
  25. const fileContents = {
  26. 'info.txt': Buffer.from('This information is critical', {
  27. encoding: 'utf-8',
  28. }),
  29. 'other.txt': Buffer.from('Some other content', {
  30. encoding: 'utf-8',
  31. }),
  32. }
  33. let tmpDir
  34. let location
  35. let notADirPath
  36. const files = {
  37. wombat: 'animals/wombat.tex',
  38. giraffe: 'animals/giraffe.tex',
  39. potato: 'vegetables/potato.tex',
  40. }
  41. beforeEach(function () {
  42. tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'fs-persistor-test-'))
  43. createTree(tmpDir, {
  44. uploads: {
  45. 'info.txt': fileContents['info.txt'],
  46. 'other.txt': fileContents['other.txt'],
  47. },
  48. 'not-a-dir':
  49. 'This regular file is meant to prevent using this path as a directory',
  50. directory: {
  51. subdirectory: {},
  52. },
  53. })
  54. notADirPath = Path.join(tmpDir, 'not-a-dir')
  55. location = Path.join(tmpDir, 'bucket')
  56. })
  57. afterEach(function () {
  58. fs.rmSync(tmpDir, { recursive: true })
  59. })
  60. const scenarios = [
  61. {
  62. description: 'default settings',
  63. settings: {},
  64. fsPath: key => Path.join(location, key.replaceAll('/', '_')),
  65. },
  66. {
  67. description: 'with useSubdirectories = true',
  68. settings: { useSubdirectories: true },
  69. fsPath: key => Path.join(location, key),
  70. },
  71. ]
  72. for (const scenario of scenarios) {
  73. describe(scenario.description, function () {
  74. let persistor
  75. beforeEach(function () {
  76. const FSPersistor = SandboxedModule.require(MODULE_PATH, {
  77. requires: {
  78. 'fs/promises': fsPromises,
  79. 'stream/promises': StreamPromises,
  80. './Errors': Errors,
  81. },
  82. })
  83. persistor = new FSPersistor(scenario.settings)
  84. })
  85. describe('sendFile', function () {
  86. it('should copy the file', async function () {
  87. await persistor.sendFile(
  88. location,
  89. files.wombat,
  90. Path.join(tmpDir, 'uploads', 'info.txt')
  91. )
  92. const contents = await fsPromises.readFile(
  93. scenario.fsPath(files.wombat)
  94. )
  95. expect(contents.equals(fileContents['info.txt'])).to.be.true
  96. })
  97. it('should return an error if the file cannot be stored', async function () {
  98. await expect(
  99. persistor.sendFile(
  100. notADirPath,
  101. files.wombat,
  102. Path.join(tmpDir, 'uploads', 'info.txt')
  103. )
  104. ).to.be.rejectedWith(Errors.WriteError)
  105. })
  106. })
  107. describe('sendStream', function () {
  108. let stream
  109. describe("when the file doesn't exist", function () {
  110. beforeEach(function () {
  111. stream = fs.createReadStream(
  112. Path.join(tmpDir, 'uploads', 'info.txt')
  113. )
  114. })
  115. it('should write the stream to disk', async function () {
  116. await persistor.sendStream(location, files.wombat, stream)
  117. const contents = await fsPromises.readFile(
  118. scenario.fsPath(files.wombat)
  119. )
  120. expect(contents.equals(fileContents['info.txt'])).to.be.true
  121. })
  122. it('should delete the temporary file', async function () {
  123. await persistor.sendStream(location, files.wombat, stream)
  124. const entries = await fsPromises.readdir(location)
  125. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  126. expect(tempDirs).to.be.empty
  127. })
  128. describe('on error', function () {
  129. beforeEach(async function () {
  130. await expect(
  131. persistor.sendStream(notADirPath, files.wombat, stream)
  132. ).to.be.rejectedWith(Errors.WriteError)
  133. })
  134. it('should not write the target file', async function () {
  135. await expect(fsPromises.access(scenario.fsPath(files.wombat))).to
  136. .be.rejected
  137. })
  138. it('should delete the temporary file', async function () {
  139. await persistor.sendStream(location, files.wombat, stream)
  140. const entries = await fsPromises.readdir(location)
  141. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  142. expect(tempDirs).to.be.empty
  143. })
  144. })
  145. describe('when the md5 hash matches', function () {
  146. it('should write the stream to disk', async function () {
  147. await persistor.sendStream(location, files.wombat, stream, {
  148. sourceMd5: md5(fileContents['info.txt']),
  149. })
  150. const contents = await fsPromises.readFile(
  151. scenario.fsPath(files.wombat)
  152. )
  153. expect(contents.equals(fileContents['info.txt'])).to.be.true
  154. })
  155. })
  156. describe('when the md5 hash does not match', function () {
  157. beforeEach(async function () {
  158. await expect(
  159. persistor.sendStream(location, files.wombat, stream, {
  160. sourceMd5: md5('wrong content'),
  161. })
  162. ).to.be.rejectedWith(Errors.WriteError)
  163. })
  164. it('should not write the target file', async function () {
  165. await expect(fsPromises.access(scenario.fsPath(files.wombat))).to
  166. .be.rejected
  167. })
  168. it('should delete the temporary file', async function () {
  169. await persistor.sendStream(location, files.wombat, stream)
  170. const entries = await fsPromises.readdir(location)
  171. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  172. expect(tempDirs).to.be.empty
  173. })
  174. })
  175. })
  176. describe('when the file already exists', function () {
  177. let stream
  178. beforeEach(async function () {
  179. await persistor.sendFile(
  180. location,
  181. files.wombat,
  182. Path.join(tmpDir, 'uploads', 'info.txt')
  183. )
  184. stream = fs.createReadStream(
  185. Path.join(tmpDir, 'uploads', 'other.txt')
  186. )
  187. })
  188. it('should write the stream to disk', async function () {
  189. await persistor.sendStream(location, files.wombat, stream)
  190. const contents = await fsPromises.readFile(
  191. scenario.fsPath(files.wombat)
  192. )
  193. expect(contents.equals(fileContents['other.txt'])).to.be.true
  194. })
  195. it('should delete the temporary file', async function () {
  196. await persistor.sendStream(location, files.wombat, stream)
  197. const entries = await fsPromises.readdir(location)
  198. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  199. expect(tempDirs).to.be.empty
  200. })
  201. describe('on error', function () {
  202. beforeEach(async function () {
  203. await expect(
  204. persistor.sendStream(notADirPath, files.wombat, stream)
  205. ).to.be.rejectedWith(Errors.WriteError)
  206. })
  207. it('should not update the target file', async function () {
  208. const contents = await fsPromises.readFile(
  209. scenario.fsPath(files.wombat)
  210. )
  211. expect(contents.equals(fileContents['info.txt'])).to.be.true
  212. })
  213. it('should delete the temporary file', async function () {
  214. await persistor.sendStream(location, files.wombat, stream)
  215. const entries = await fsPromises.readdir(location)
  216. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  217. expect(tempDirs).to.be.empty
  218. })
  219. })
  220. describe('when the md5 hash matches', function () {
  221. it('should write the stream to disk', async function () {
  222. await persistor.sendStream(location, files.wombat, stream, {
  223. sourceMd5: md5(fileContents['other.txt']),
  224. })
  225. const contents = await fsPromises.readFile(
  226. scenario.fsPath(files.wombat)
  227. )
  228. expect(contents.equals(fileContents['other.txt'])).to.be.true
  229. })
  230. })
  231. describe('when the md5 hash does not match', function () {
  232. beforeEach(async function () {
  233. await expect(
  234. persistor.sendStream(location, files.wombat, stream, {
  235. sourceMd5: md5('wrong content'),
  236. })
  237. ).to.be.rejectedWith(Errors.WriteError)
  238. })
  239. it('should not update the target file', async function () {
  240. const contents = await fsPromises.readFile(
  241. scenario.fsPath(files.wombat)
  242. )
  243. expect(contents.equals(fileContents['info.txt'])).to.be.true
  244. })
  245. it('should delete the temporary file', async function () {
  246. await persistor.sendStream(location, files.wombat, stream)
  247. const entries = await fsPromises.readdir(location)
  248. const tempDirs = entries.filter(dir => dir.startsWith('tmp-'))
  249. expect(tempDirs).to.be.empty
  250. })
  251. })
  252. })
  253. })
  254. describe('getObjectStream', function () {
  255. beforeEach(async function () {
  256. await persistor.sendFile(
  257. location,
  258. files.wombat,
  259. Path.join(tmpDir, 'uploads', 'info.txt')
  260. )
  261. })
  262. it('should return a string with the object contents', async function () {
  263. const stream = await persistor.getObjectStream(location, files.wombat)
  264. const contents = await streamToBuffer(stream)
  265. expect(contents.equals(fileContents['info.txt'])).to.be.true
  266. })
  267. it('should support ranges', async function () {
  268. const stream = await persistor.getObjectStream(
  269. location,
  270. files.wombat,
  271. {
  272. start: 5,
  273. end: 16,
  274. }
  275. )
  276. const contents = await streamToBuffer(stream)
  277. // end is inclusive in ranges, but exclusive in slice()
  278. expect(contents.equals(fileContents['info.txt'].slice(5, 17))).to.be
  279. .true
  280. })
  281. it('should give a NotFoundError if the file does not exist', async function () {
  282. await expect(
  283. persistor.getObjectStream(location, 'does-not-exist')
  284. ).to.be.rejectedWith(Errors.NotFoundError)
  285. })
  286. })
  287. describe('getObjectSize', function () {
  288. beforeEach(async function () {
  289. await persistor.sendFile(
  290. location,
  291. files.wombat,
  292. Path.join(tmpDir, 'uploads', 'info.txt')
  293. )
  294. })
  295. it('should return the file size', async function () {
  296. expect(
  297. await persistor.getObjectSize(location, files.wombat)
  298. ).to.equal(fileContents['info.txt'].length)
  299. })
  300. it('should throw a NotFoundError if the file does not exist', async function () {
  301. await expect(
  302. persistor.getObjectSize(location, 'does-not-exist')
  303. ).to.be.rejectedWith(Errors.NotFoundError)
  304. })
  305. })
  306. describe('copyObject', function () {
  307. beforeEach(async function () {
  308. await persistor.sendFile(
  309. location,
  310. files.wombat,
  311. Path.join(tmpDir, 'uploads', 'info.txt')
  312. )
  313. })
  314. it('Should copy the file to the new location', async function () {
  315. await persistor.copyObject(location, files.wombat, files.potato)
  316. const contents = await fsPromises.readFile(
  317. scenario.fsPath(files.potato)
  318. )
  319. expect(contents.equals(fileContents['info.txt'])).to.be.true
  320. })
  321. })
  322. describe('deleteObject', function () {
  323. beforeEach(async function () {
  324. await persistor.sendFile(
  325. location,
  326. files.wombat,
  327. Path.join(tmpDir, 'uploads', 'info.txt')
  328. )
  329. await fsPromises.access(scenario.fsPath(files.wombat))
  330. })
  331. it('should delete the file', async function () {
  332. await persistor.deleteObject(location, files.wombat)
  333. await expect(fsPromises.access(scenario.fsPath(files.wombat))).to.be
  334. .rejected
  335. })
  336. it("should ignore files that don't exist", async function () {
  337. await persistor.deleteObject(location, 'does-not-exist')
  338. })
  339. })
  340. describe('deleteDirectory', function () {
  341. beforeEach(async function () {
  342. for (const file of Object.values(files)) {
  343. await persistor.sendFile(
  344. location,
  345. file,
  346. Path.join(tmpDir, 'uploads', 'info.txt')
  347. )
  348. await fsPromises.access(scenario.fsPath(file))
  349. }
  350. })
  351. it('should delete all files under the directory', async function () {
  352. await persistor.deleteDirectory(location, 'animals')
  353. for (const file of [files.wombat, files.giraffe]) {
  354. await expect(fsPromises.access(scenario.fsPath(file))).to.be
  355. .rejected
  356. }
  357. })
  358. it('should not delete files under other directoris', async function () {
  359. await persistor.deleteDirectory(location, 'animals')
  360. await fsPromises.access(scenario.fsPath(files.potato))
  361. })
  362. it("should ignore directories that don't exist", async function () {
  363. await persistor.deleteDirectory(location, 'does-not-exist')
  364. for (const file of Object.values(files)) {
  365. await fsPromises.access(scenario.fsPath(file))
  366. }
  367. })
  368. })
  369. describe('checkIfObjectExists', function () {
  370. beforeEach(async function () {
  371. await persistor.sendFile(
  372. location,
  373. files.wombat,
  374. Path.join(tmpDir, 'uploads', 'info.txt')
  375. )
  376. })
  377. it('should return true for existing files', async function () {
  378. expect(
  379. await persistor.checkIfObjectExists(location, files.wombat)
  380. ).to.equal(true)
  381. })
  382. it('should return false for non-existing files', async function () {
  383. expect(
  384. await persistor.checkIfObjectExists(location, 'does-not-exist')
  385. ).to.equal(false)
  386. })
  387. })
  388. describe('directorySize', function () {
  389. beforeEach(async function () {
  390. for (const file of Object.values(files)) {
  391. await persistor.sendFile(
  392. location,
  393. file,
  394. Path.join(tmpDir, 'uploads', 'info.txt')
  395. )
  396. }
  397. })
  398. it('should sum directory files size', async function () {
  399. expect(await persistor.directorySize(location, 'animals')).to.equal(
  400. 2 * fileContents['info.txt'].length
  401. )
  402. })
  403. it('should return 0 on non-existing directories', async function () {
  404. expect(
  405. await persistor.directorySize(location, 'does-not-exist')
  406. ).to.equal(0)
  407. })
  408. })
  409. describe('listDirectoryKeys', function () {
  410. beforeEach(async function () {
  411. for (const file of Object.values(files)) {
  412. await persistor.sendFile(
  413. location,
  414. file,
  415. Path.join(tmpDir, 'uploads', 'info.txt')
  416. )
  417. }
  418. })
  419. it('should list directory keys', async function () {
  420. const keys = await persistor.listDirectoryKeys(location, 'animals')
  421. expect(keys).to.have.lengthOf(2)
  422. expect(keys).to.include(scenario.fsPath(files.wombat))
  423. expect(keys).to.include(scenario.fsPath(files.giraffe))
  424. })
  425. it('should return empty array for non-existing directories', async function () {
  426. const keys = await persistor.listDirectoryKeys(
  427. location,
  428. 'does-not-exist'
  429. )
  430. expect(keys).to.deep.equal([])
  431. })
  432. })
  433. describe('listDirectoryStats', function () {
  434. beforeEach(async function () {
  435. for (const file of Object.values(files)) {
  436. await persistor.sendFile(
  437. location,
  438. file,
  439. Path.join(tmpDir, 'uploads', 'info.txt')
  440. )
  441. }
  442. })
  443. it('should list directory stats', async function () {
  444. const stats = await persistor.listDirectoryStats(location, 'animals')
  445. expect(stats).to.have.lengthOf(2)
  446. const keys = stats.map(s => s.key)
  447. expect(keys).to.include(scenario.fsPath(files.wombat))
  448. expect(keys).to.include(scenario.fsPath(files.giraffe))
  449. for (const stat of stats) {
  450. expect(stat.size).to.equal(fileContents['info.txt'].length)
  451. }
  452. })
  453. it('should return empty array for non-existing directories', async function () {
  454. const stats = await persistor.listDirectoryStats(
  455. location,
  456. 'does-not-exist'
  457. )
  458. expect(stats).to.deep.equal([])
  459. })
  460. })
  461. })
  462. }
  463. })
  464. function md5(str) {
  465. return crypto.createHash('md5').update(str).digest('hex')
  466. }
  467. async function streamToBuffer(stream) {
  468. const chunks = []
  469. for await (const chunk of stream) {
  470. chunks.push(chunk)
  471. }
  472. return Buffer.concat(chunks)
  473. }