S3PersistorTests.js 29 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048
  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 { EventEmitter } = require('node:events')
  7. const MODULE_PATH = '../../src/S3Persistor.js'
  8. describe('S3PersistorTests', function () {
  9. const defaultS3Key = 'frog'
  10. const defaultS3Secret = 'prince'
  11. const defaultS3Credentials = {
  12. credentials: {
  13. accessKeyId: defaultS3Key,
  14. secretAccessKey: defaultS3Secret,
  15. },
  16. }
  17. const filename = '/wombat/potato.tex'
  18. const bucket = 'womBucket'
  19. const key = 'monKey'
  20. const destKey = 'donKey'
  21. const objectSize = 5555
  22. const genericError = new Error('guru meditation error')
  23. const files = [
  24. { Key: 'llama', Size: 11 },
  25. { Key: 'hippo', Size: 22 },
  26. ]
  27. const filesSize = 33
  28. const md5 = 'ffffffff00000000ffffffff00000000'
  29. const redirectUrl = 'https://wombat.potato/giraffe'
  30. let Logger,
  31. Transform,
  32. PassThrough,
  33. S3,
  34. Fs,
  35. ReadStream,
  36. Stream,
  37. StreamPromises,
  38. S3GetObjectRequest,
  39. S3Persistor,
  40. S3Client,
  41. S3NotFoundError,
  42. S3AccessDeniedError,
  43. FileNotFoundError,
  44. EmptyPromise,
  45. settings,
  46. Hash,
  47. crypto
  48. beforeEach(function () {
  49. settings = {
  50. secret: defaultS3Secret,
  51. key: defaultS3Key,
  52. partSize: 100 * 1024 * 1024,
  53. }
  54. Transform = class {
  55. once() {}
  56. }
  57. PassThrough = class {}
  58. Stream = {
  59. Transform,
  60. PassThrough,
  61. pipeline: sinon.stub().yields(),
  62. }
  63. StreamPromises = {
  64. pipeline: sinon.stub().resolves(),
  65. }
  66. EmptyPromise = {
  67. promise: sinon.stub().resolves(),
  68. }
  69. ReadStream = new EventEmitter()
  70. class FakeS3GetObjectRequest extends EventEmitter {
  71. constructor() {
  72. super()
  73. this.statusCode = 200
  74. this.err = null
  75. this.aborted = false
  76. }
  77. abort() {
  78. this.aborted = true
  79. }
  80. createReadStream() {
  81. setTimeout(() => {
  82. if (this.notFoundSSEC) {
  83. // special case for AWS S3: 404 NoSuchKey wrapped in a 400. A single request received a single response, and multiple httpHeaders events are triggered. Don't ask.
  84. this.emit('httpHeaders', 400, {})
  85. this.emit('httpHeaders', 404, {})
  86. ReadStream.emit('error', S3NotFoundError)
  87. return
  88. }
  89. if (this.err) return ReadStream.emit('error', this.err)
  90. this.emit('httpHeaders', this.statusCode, {})
  91. if (this.statusCode === 403) {
  92. ReadStream.emit('error', S3AccessDeniedError)
  93. }
  94. if (this.statusCode === 404) {
  95. ReadStream.emit('error', S3NotFoundError)
  96. }
  97. })
  98. return ReadStream
  99. }
  100. }
  101. S3GetObjectRequest = new FakeS3GetObjectRequest()
  102. FileNotFoundError = new Error('File not found')
  103. FileNotFoundError.code = 'ENOENT'
  104. Fs = {
  105. createReadStream: sinon.stub().returns(ReadStream),
  106. }
  107. S3NotFoundError = new Error('not found')
  108. S3NotFoundError.code = 'NoSuchKey'
  109. S3AccessDeniedError = new Error('access denied')
  110. S3AccessDeniedError.code = 'AccessDenied'
  111. S3Client = {
  112. getObject: sinon.stub().returns(S3GetObjectRequest),
  113. headObject: sinon.stub().returns({
  114. promise: sinon.stub().resolves({
  115. ContentLength: objectSize,
  116. ETag: md5,
  117. }),
  118. }),
  119. listObjectsV2: sinon.stub().returns({
  120. promise: sinon.stub().resolves({
  121. Contents: files,
  122. }),
  123. }),
  124. upload: sinon
  125. .stub()
  126. .returns({ promise: sinon.stub().resolves({ ETag: `"${md5}"` }) }),
  127. copyObject: sinon.stub().returns(EmptyPromise),
  128. deleteObject: sinon.stub().returns(EmptyPromise),
  129. deleteObjects: sinon.stub().returns(EmptyPromise),
  130. getSignedUrlPromise: sinon.stub().resolves(redirectUrl),
  131. }
  132. S3 = sinon.stub().callsFake(() => Object.assign({}, S3Client))
  133. Hash = {
  134. end: sinon.stub(),
  135. read: sinon.stub().returns(md5),
  136. setEncoding: sinon.stub(),
  137. }
  138. crypto = {
  139. createHash: sinon.stub().returns(Hash),
  140. }
  141. Logger = {
  142. warn: sinon.stub(),
  143. }
  144. S3Persistor = new (SandboxedModule.require(MODULE_PATH, {
  145. requires: {
  146. 'aws-sdk/clients/s3': S3,
  147. '@overleaf/logger': Logger,
  148. './Errors': Errors,
  149. fs: Fs,
  150. stream: Stream,
  151. 'stream/promises': StreamPromises,
  152. crypto,
  153. },
  154. globals: { console, Buffer },
  155. }).S3Persistor)(settings)
  156. })
  157. describe('getObjectStream', function () {
  158. describe('when called with valid parameters', function () {
  159. let stream
  160. beforeEach(async function () {
  161. stream = await S3Persistor.getObjectStream(bucket, key)
  162. })
  163. it('returns a PassThrough stream', function () {
  164. expect(stream).to.be.instanceOf(PassThrough)
  165. })
  166. it('sets the AWS client up with credentials from settings', function () {
  167. expect(S3).to.have.been.calledWith(defaultS3Credentials)
  168. })
  169. it('fetches the right key from the right bucket', function () {
  170. expect(S3Client.getObject).to.have.been.calledWith({
  171. Bucket: bucket,
  172. Key: key,
  173. })
  174. })
  175. it('pipes the stream through the meter', async function () {
  176. expect(Stream.pipeline).to.have.been.calledWith(
  177. ReadStream,
  178. sinon.match.instanceOf(Transform),
  179. sinon.match.instanceOf(PassThrough)
  180. )
  181. })
  182. it('does not abort the request', function () {
  183. expect(S3GetObjectRequest.aborted).to.equal(false)
  184. })
  185. })
  186. describe('when called with a byte range', function () {
  187. let stream
  188. beforeEach(async function () {
  189. stream = await S3Persistor.getObjectStream(bucket, key, {
  190. start: 5,
  191. end: 10,
  192. })
  193. })
  194. it('returns a PassThrough stream', function () {
  195. expect(stream).to.be.instanceOf(Stream.PassThrough)
  196. })
  197. it('passes the byte range on to S3', function () {
  198. expect(S3Client.getObject).to.have.been.calledWith({
  199. Bucket: bucket,
  200. Key: key,
  201. Range: 'bytes=5-10',
  202. })
  203. })
  204. })
  205. describe('when streaming fails', function () {
  206. let stream
  207. beforeEach(async function () {
  208. Stream.pipeline.yields(new Error())
  209. stream = await S3Persistor.getObjectStream(bucket, key)
  210. })
  211. it('returns a PassThrough stream', function () {
  212. expect(stream).to.be.instanceOf(Stream.PassThrough)
  213. })
  214. it('aborts the request', function () {
  215. expect(S3GetObjectRequest.aborted).to.equal(true)
  216. })
  217. })
  218. describe('when there are alternative credentials', function () {
  219. let stream
  220. const alternativeSecret = 'giraffe'
  221. const alternativeKey = 'hippo'
  222. const alternativeS3Credentials = {
  223. credentials: {
  224. accessKeyId: alternativeKey,
  225. secretAccessKey: alternativeSecret,
  226. },
  227. }
  228. beforeEach(async function () {
  229. settings.bucketCreds = {}
  230. settings.bucketCreds[bucket] = {
  231. auth_key: alternativeKey,
  232. auth_secret: alternativeSecret,
  233. }
  234. stream = await S3Persistor.getObjectStream(bucket, key)
  235. })
  236. it('returns a PassThrough stream', function () {
  237. expect(stream).to.be.instanceOf(Stream.PassThrough)
  238. })
  239. it('sets the AWS client up with the alternative credentials', function () {
  240. expect(S3).to.have.been.calledWith(alternativeS3Credentials)
  241. })
  242. it('fetches the right key from the right bucket', function () {
  243. expect(S3Client.getObject).to.have.been.calledWith({
  244. Bucket: bucket,
  245. Key: key,
  246. })
  247. })
  248. it('uses the default credentials for an unknown bucket', async function () {
  249. stream = await S3Persistor.getObjectStream('anotherBucket', key)
  250. expect(S3).to.have.been.calledTwice
  251. expect(S3.firstCall).to.have.been.calledWith(alternativeS3Credentials)
  252. expect(S3.secondCall).to.have.been.calledWith(defaultS3Credentials)
  253. })
  254. })
  255. describe('without hard-coded credentials', function () {
  256. it('uses the default provider chain', async function () {
  257. delete settings.key
  258. delete settings.secret
  259. await S3Persistor.getObjectStream(bucket, key)
  260. expect(S3).to.have.been.calledOnce
  261. expect(S3.args[0].credentials).to.not.exist
  262. })
  263. })
  264. describe('when given S3 options', function () {
  265. const httpOptions = { timeout: 2000 }
  266. const maxRetries = 2
  267. beforeEach(async function () {
  268. settings.httpOptions = httpOptions
  269. settings.maxRetries = maxRetries
  270. await S3Persistor.getObjectStream(bucket, key)
  271. })
  272. it('configures the S3 client appropriately', function () {
  273. expect(S3).to.have.been.calledWithMatch({ httpOptions, maxRetries })
  274. })
  275. })
  276. describe("when the file doesn't exist", function () {
  277. let error, stream
  278. beforeEach(async function () {
  279. S3GetObjectRequest.statusCode = 404
  280. try {
  281. stream = await S3Persistor.getObjectStream(bucket, key)
  282. } catch (err) {
  283. error = err
  284. }
  285. })
  286. it('does not return a stream', function () {
  287. expect(stream).not.to.exist
  288. })
  289. it('throws a NotFoundError', function () {
  290. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  291. })
  292. it('wraps the error', function () {
  293. expect(error.cause).to.exist
  294. })
  295. it('stores the bucket and key in the error', function () {
  296. expect(error.info).to.include({ bucketName: bucket, key })
  297. })
  298. })
  299. describe("when the file doesn't exist -- SSEC", function () {
  300. let error, stream
  301. beforeEach(async function () {
  302. S3GetObjectRequest.notFoundSSEC = 404
  303. try {
  304. stream = await S3Persistor.getObjectStream(bucket, key)
  305. } catch (err) {
  306. error = err
  307. }
  308. })
  309. it('does not return a stream', function () {
  310. expect(stream).not.to.exist
  311. })
  312. it('throws a NotFoundError', function () {
  313. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  314. })
  315. it('wraps the error', function () {
  316. expect(error.cause).to.exist
  317. })
  318. it('stores the bucket and key in the error', function () {
  319. expect(error.info).to.include({ bucketName: bucket, key })
  320. })
  321. })
  322. describe('when access to the file is denied', function () {
  323. let error, stream
  324. beforeEach(async function () {
  325. S3GetObjectRequest.statusCode = 403
  326. try {
  327. stream = await S3Persistor.getObjectStream(bucket, key)
  328. } catch (err) {
  329. error = err
  330. }
  331. })
  332. it('does not return a stream', function () {
  333. expect(stream).not.to.exist
  334. })
  335. it('throws a NotFoundError', function () {
  336. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  337. })
  338. it('wraps the error', function () {
  339. expect(error.cause).to.equal(S3AccessDeniedError)
  340. })
  341. it('stores the bucket and key in the error', function () {
  342. expect(error.info).to.include({ bucketName: bucket, key })
  343. })
  344. })
  345. describe('when S3 encounters an unknown error', function () {
  346. let error, stream
  347. beforeEach(async function () {
  348. S3GetObjectRequest.err = genericError
  349. try {
  350. stream = await S3Persistor.getObjectStream(bucket, key)
  351. } catch (err) {
  352. error = err
  353. }
  354. })
  355. it('does not return a stream', function () {
  356. expect(stream).not.to.exist
  357. })
  358. it('throws a ReadError', function () {
  359. expect(error).to.be.an.instanceOf(Errors.ReadError)
  360. })
  361. it('wraps the error', function () {
  362. expect(error.cause).to.exist
  363. })
  364. it('stores the bucket and key in the error', function () {
  365. expect(error.info).to.include({ bucketName: bucket, key })
  366. })
  367. })
  368. })
  369. describe('getRedirectUrl', function () {
  370. let signedUrl
  371. beforeEach(async function () {
  372. signedUrl = await S3Persistor.getRedirectUrl(bucket, key)
  373. })
  374. it('should request a signed URL', function () {
  375. expect(S3Client.getSignedUrlPromise).to.have.been.called
  376. })
  377. it('should return the url', function () {
  378. expect(signedUrl).to.equal(redirectUrl)
  379. })
  380. })
  381. describe('getObjectSize', function () {
  382. describe('when called with valid parameters', function () {
  383. let size
  384. beforeEach(async function () {
  385. size = await S3Persistor.getObjectSize(bucket, key)
  386. })
  387. it('should return the object size', function () {
  388. expect(size).to.equal(objectSize)
  389. })
  390. it('should pass the bucket and key to S3', function () {
  391. expect(S3Client.headObject).to.have.been.calledWith({
  392. Bucket: bucket,
  393. Key: key,
  394. })
  395. })
  396. })
  397. describe('when the object is not found', function () {
  398. let error
  399. beforeEach(async function () {
  400. S3Client.headObject = sinon.stub().returns({
  401. promise: sinon.stub().rejects(S3NotFoundError),
  402. })
  403. try {
  404. await S3Persistor.getObjectSize(bucket, key)
  405. } catch (err) {
  406. error = err
  407. }
  408. })
  409. it('should return a NotFoundError', function () {
  410. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  411. })
  412. it('should wrap the error', function () {
  413. expect(error.cause).to.equal(S3NotFoundError)
  414. })
  415. })
  416. describe('when S3 returns an error', function () {
  417. let error
  418. beforeEach(async function () {
  419. S3Client.headObject = sinon.stub().returns({
  420. promise: sinon.stub().rejects(genericError),
  421. })
  422. try {
  423. await S3Persistor.getObjectSize(bucket, key)
  424. } catch (err) {
  425. error = err
  426. }
  427. })
  428. it('should return a ReadError', function () {
  429. expect(error).to.be.an.instanceOf(Errors.ReadError)
  430. })
  431. it('should wrap the error', function () {
  432. expect(error.cause).to.equal(genericError)
  433. })
  434. })
  435. })
  436. describe('sendStream', function () {
  437. describe('with valid parameters', function () {
  438. beforeEach(async function () {
  439. return S3Persistor.sendStream(bucket, key, ReadStream)
  440. })
  441. it('should upload the stream', function () {
  442. expect(S3Client.upload).to.have.been.calledWith({
  443. Bucket: bucket,
  444. Key: key,
  445. Body: sinon.match.instanceOf(Stream.Transform),
  446. })
  447. })
  448. it('should upload files in a single part', function () {
  449. expect(S3Client.upload).to.have.been.calledWith(sinon.match.any, {
  450. partSize: 100 * 1024 * 1024,
  451. })
  452. })
  453. it('should meter the stream', function () {
  454. expect(Stream.pipeline).to.have.been.calledWith(
  455. ReadStream,
  456. sinon.match.instanceOf(Stream.Transform)
  457. )
  458. })
  459. })
  460. describe('when a hash is supplied', function () {
  461. beforeEach(async function () {
  462. return S3Persistor.sendStream(bucket, key, ReadStream, {
  463. sourceMd5: 'aaaaaaaabbbbbbbbaaaaaaaabbbbbbbb',
  464. })
  465. })
  466. it('sends the hash in base64', function () {
  467. expect(S3Client.upload).to.have.been.calledWith({
  468. Bucket: bucket,
  469. Key: key,
  470. Body: sinon.match.instanceOf(Transform),
  471. ContentMD5: 'qqqqqru7u7uqqqqqu7u7uw==',
  472. })
  473. })
  474. })
  475. describe('when metadata is supplied', function () {
  476. const contentType = 'text/csv'
  477. const contentEncoding = 'gzip'
  478. beforeEach(async function () {
  479. return S3Persistor.sendStream(bucket, key, ReadStream, {
  480. contentType,
  481. contentEncoding,
  482. })
  483. })
  484. it('sends the metadata to S3', function () {
  485. expect(S3Client.upload).to.have.been.calledWith({
  486. Bucket: bucket,
  487. Key: key,
  488. Body: sinon.match.instanceOf(Transform),
  489. ContentType: contentType,
  490. ContentEncoding: contentEncoding,
  491. })
  492. })
  493. })
  494. describe('when the upload fails', function () {
  495. let error
  496. beforeEach(async function () {
  497. S3Client.upload = sinon.stub().returns({
  498. promise: sinon.stub().rejects(genericError),
  499. })
  500. try {
  501. await S3Persistor.sendStream(bucket, key, ReadStream)
  502. } catch (err) {
  503. error = err
  504. }
  505. })
  506. it('throws a WriteError', function () {
  507. expect(error).to.be.an.instanceOf(Errors.WriteError)
  508. })
  509. })
  510. })
  511. describe('sendFile', function () {
  512. describe('with valid parameters', function () {
  513. beforeEach(async function () {
  514. return S3Persistor.sendFile(bucket, key, filename)
  515. })
  516. it('should create a read stream for the file', function () {
  517. expect(Fs.createReadStream).to.have.been.calledWith(filename)
  518. })
  519. it('should upload the stream', function () {
  520. expect(S3Client.upload).to.have.been.calledWith({
  521. Bucket: bucket,
  522. Key: key,
  523. Body: sinon.match.instanceOf(Transform),
  524. })
  525. })
  526. })
  527. })
  528. describe('getObjectMd5Hash', function () {
  529. describe('when the etag is a valid md5 hash', function () {
  530. let hash
  531. beforeEach(async function () {
  532. hash = await S3Persistor.getObjectMd5Hash(bucket, key)
  533. })
  534. it('should return the object hash', function () {
  535. expect(hash).to.equal(md5)
  536. })
  537. it('should get the hash from the object metadata', function () {
  538. expect(S3Client.headObject).to.have.been.calledWith({
  539. Bucket: bucket,
  540. Key: key,
  541. })
  542. })
  543. it('should not download the object', function () {
  544. expect(S3Client.getObject).not.to.have.been.called
  545. })
  546. })
  547. describe("when the etag isn't a valid md5 hash", function () {
  548. let hash
  549. beforeEach(async function () {
  550. S3Client.headObject = sinon.stub().returns({
  551. promise: sinon.stub().resolves({
  552. ETag: 'somethingthatisntanmd5',
  553. Bucket: bucket,
  554. Key: key,
  555. }),
  556. })
  557. hash = await S3Persistor.getObjectMd5Hash(bucket, key)
  558. })
  559. it('should re-fetch the file to verify it', function () {
  560. expect(S3Client.getObject).to.have.been.calledWith({
  561. Bucket: bucket,
  562. Key: key,
  563. })
  564. })
  565. it('should calculate the md5 hash from the file', function () {
  566. expect(Hash.read).to.have.been.called
  567. })
  568. it('should return the md5 hash', function () {
  569. expect(hash).to.equal(md5)
  570. })
  571. })
  572. })
  573. describe('copyObject', function () {
  574. describe('with valid parameters', function () {
  575. beforeEach(async function () {
  576. return S3Persistor.copyObject(bucket, key, destKey)
  577. })
  578. it('should copy the object', function () {
  579. expect(S3Client.copyObject).to.have.been.calledWith({
  580. Bucket: bucket,
  581. Key: destKey,
  582. CopySource: `${bucket}/${key}`,
  583. })
  584. })
  585. })
  586. describe('when the file does not exist', function () {
  587. let error
  588. beforeEach(async function () {
  589. S3Client.copyObject = sinon.stub().returns({
  590. promise: sinon.stub().rejects(S3NotFoundError),
  591. })
  592. try {
  593. await S3Persistor.copyObject(bucket, key, destKey)
  594. } catch (err) {
  595. error = err
  596. }
  597. })
  598. it('should throw a NotFoundError', function () {
  599. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  600. })
  601. })
  602. })
  603. describe('deleteObject', function () {
  604. describe('with valid parameters', function () {
  605. beforeEach(async function () {
  606. return S3Persistor.deleteObject(bucket, key)
  607. })
  608. it('should delete the object', function () {
  609. expect(S3Client.deleteObject).to.have.been.calledWith({
  610. Bucket: bucket,
  611. Key: key,
  612. })
  613. })
  614. })
  615. })
  616. describe('deleteDirectory', function () {
  617. describe('with valid parameters', function () {
  618. beforeEach(async function () {
  619. return S3Persistor.deleteDirectory(bucket, key)
  620. })
  621. it('should list the objects in the directory', function () {
  622. expect(S3Client.listObjectsV2).to.have.been.calledWith({
  623. Bucket: bucket,
  624. Prefix: key,
  625. })
  626. })
  627. it('should delete the objects using their keys', function () {
  628. expect(S3Client.deleteObjects).to.have.been.calledWith({
  629. Bucket: bucket,
  630. Delete: {
  631. Objects: [{ Key: 'llama' }, { Key: 'hippo' }],
  632. Quiet: true,
  633. },
  634. })
  635. })
  636. })
  637. describe('when there are no files', function () {
  638. beforeEach(async function () {
  639. S3Client.listObjectsV2 = sinon
  640. .stub()
  641. .returns({ promise: sinon.stub().resolves({ Contents: [] }) })
  642. return S3Persistor.deleteDirectory(bucket, key)
  643. })
  644. it('should list the objects in the directory', function () {
  645. expect(S3Client.listObjectsV2).to.have.been.calledWith({
  646. Bucket: bucket,
  647. Prefix: key,
  648. })
  649. })
  650. it('should not try to delete any objects', function () {
  651. expect(S3Client.deleteObjects).not.to.have.been.called
  652. })
  653. })
  654. describe('when there are more files available', function () {
  655. const continuationToken = 'wombat'
  656. beforeEach(async function () {
  657. S3Client.listObjectsV2.onCall(0).returns({
  658. promise: sinon.stub().resolves({
  659. Contents: files,
  660. IsTruncated: true,
  661. NextContinuationToken: continuationToken,
  662. }),
  663. })
  664. return S3Persistor.deleteDirectory(bucket, key)
  665. })
  666. it('should list the objects a second time, with a continuation token', function () {
  667. expect(S3Client.listObjectsV2).to.be.calledTwice
  668. expect(S3Client.listObjectsV2).to.be.calledWith({
  669. Bucket: bucket,
  670. Prefix: key,
  671. })
  672. expect(S3Client.listObjectsV2).to.be.calledWith({
  673. Bucket: bucket,
  674. Prefix: key,
  675. ContinuationToken: continuationToken,
  676. })
  677. })
  678. it('should delete both sets of files', function () {
  679. expect(S3Client.deleteObjects).to.have.been.calledTwice
  680. })
  681. })
  682. describe('when there is an error listing the objects', function () {
  683. let error
  684. beforeEach(async function () {
  685. S3Client.listObjectsV2 = sinon
  686. .stub()
  687. .returns({ promise: sinon.stub().rejects(genericError) })
  688. try {
  689. await S3Persistor.deleteDirectory(bucket, key)
  690. } catch (err) {
  691. error = err
  692. }
  693. })
  694. it('should generate a ReadError', function () {
  695. expect(error).to.be.an.instanceOf(Errors.ReadError)
  696. })
  697. it('should wrap the error', function () {
  698. expect(error.cause).to.equal(genericError)
  699. })
  700. it('should not try to delete any objects', function () {
  701. expect(S3Client.deleteObjects).not.to.have.been.called
  702. })
  703. })
  704. describe('when there is an error deleting the objects', function () {
  705. let error
  706. beforeEach(async function () {
  707. S3Client.deleteObjects = sinon
  708. .stub()
  709. .returns({ promise: sinon.stub().rejects(genericError) })
  710. try {
  711. await S3Persistor.deleteDirectory(bucket, key)
  712. } catch (err) {
  713. error = err
  714. }
  715. })
  716. it('should generate a WriteError', function () {
  717. expect(error).to.be.an.instanceOf(Errors.WriteError)
  718. })
  719. it('should wrap the error', function () {
  720. expect(error.cause).to.equal(genericError)
  721. })
  722. })
  723. })
  724. describe('directorySize', function () {
  725. describe('with valid parameters', function () {
  726. let size
  727. beforeEach(async function () {
  728. size = await S3Persistor.directorySize(bucket, key)
  729. })
  730. it('should list the objects in the directory', function () {
  731. expect(S3Client.listObjectsV2).to.have.been.calledWith({
  732. Bucket: bucket,
  733. Prefix: key,
  734. })
  735. })
  736. it('should return the directory size', function () {
  737. expect(size).to.equal(filesSize)
  738. })
  739. })
  740. describe('when there are no files', function () {
  741. let size
  742. beforeEach(async function () {
  743. S3Client.listObjectsV2 = sinon
  744. .stub()
  745. .returns({ promise: sinon.stub().resolves({ Contents: [] }) })
  746. size = await S3Persistor.directorySize(bucket, key)
  747. })
  748. it('should list the objects in the directory', function () {
  749. expect(S3Client.listObjectsV2).to.have.been.calledWith({
  750. Bucket: bucket,
  751. Prefix: key,
  752. })
  753. })
  754. it('should return zero', function () {
  755. expect(size).to.equal(0)
  756. })
  757. })
  758. describe('when there are more files available', function () {
  759. const continuationToken = 'wombat'
  760. let size
  761. beforeEach(async function () {
  762. S3Client.listObjectsV2.onCall(0).returns({
  763. promise: sinon.stub().resolves({
  764. Contents: files,
  765. IsTruncated: true,
  766. NextContinuationToken: continuationToken,
  767. }),
  768. })
  769. size = await S3Persistor.directorySize(bucket, key)
  770. })
  771. it('should list the objects a second time, with a continuation token', function () {
  772. expect(S3Client.listObjectsV2).to.be.calledTwice
  773. expect(S3Client.listObjectsV2).to.be.calledWith({
  774. Bucket: bucket,
  775. Prefix: key,
  776. })
  777. expect(S3Client.listObjectsV2).to.be.calledWith({
  778. Bucket: bucket,
  779. Prefix: key,
  780. ContinuationToken: continuationToken,
  781. })
  782. })
  783. it('should return the size of both sets of files', function () {
  784. expect(size).to.equal(filesSize * 2)
  785. })
  786. })
  787. describe('when there is an error listing the objects', function () {
  788. let error
  789. beforeEach(async function () {
  790. S3Client.listObjectsV2 = sinon
  791. .stub()
  792. .returns({ promise: sinon.stub().rejects(genericError) })
  793. try {
  794. await S3Persistor.directorySize(bucket, key)
  795. } catch (err) {
  796. error = err
  797. }
  798. })
  799. it('should generate a ReadError', function () {
  800. expect(error).to.be.an.instanceOf(Errors.ReadError)
  801. })
  802. it('should wrap the error', function () {
  803. expect(error.cause).to.equal(genericError)
  804. })
  805. })
  806. })
  807. describe('checkIfObjectExists', function () {
  808. describe('when the file exists', function () {
  809. let exists
  810. beforeEach(async function () {
  811. exists = await S3Persistor.checkIfObjectExists(bucket, key)
  812. })
  813. it('should get the object header', function () {
  814. expect(S3Client.headObject).to.have.been.calledWith({
  815. Bucket: bucket,
  816. Key: key,
  817. })
  818. })
  819. it('should return that the file exists', function () {
  820. expect(exists).to.equal(true)
  821. })
  822. })
  823. describe('when the file does not exist', function () {
  824. let exists
  825. beforeEach(async function () {
  826. S3Client.headObject = sinon
  827. .stub()
  828. .returns({ promise: sinon.stub().rejects(S3NotFoundError) })
  829. exists = await S3Persistor.checkIfObjectExists(bucket, key)
  830. })
  831. it('should get the object header', function () {
  832. expect(S3Client.headObject).to.have.been.calledWith({
  833. Bucket: bucket,
  834. Key: key,
  835. })
  836. })
  837. it('should return that the file does not exist', function () {
  838. expect(exists).to.equal(false)
  839. })
  840. })
  841. describe('when there is an error', function () {
  842. let error
  843. beforeEach(async function () {
  844. S3Client.headObject = sinon
  845. .stub()
  846. .returns({ promise: sinon.stub().rejects(genericError) })
  847. try {
  848. await S3Persistor.checkIfObjectExists(bucket, key)
  849. } catch (err) {
  850. error = err
  851. }
  852. })
  853. it('should generate a ReadError', function () {
  854. expect(error).to.be.an.instanceOf(Errors.ReadError)
  855. })
  856. it('should wrap the upstream ReadError', function () {
  857. expect(error.cause).to.be.an.instanceOf(Errors.ReadError)
  858. })
  859. it('should eventually wrap the error', function () {
  860. expect(error.cause.cause).to.equal(genericError)
  861. })
  862. })
  863. })
  864. describe('_getClientForBucket', function () {
  865. it('should return same instance for same bucket', function () {
  866. const a = S3Persistor._getClientForBucket('foo')
  867. const b = S3Persistor._getClientForBucket('foo')
  868. expect(a).to.equal(b)
  869. })
  870. it('should return different instance for different bucket', function () {
  871. const a = S3Persistor._getClientForBucket('foo')
  872. const b = S3Persistor._getClientForBucket('bar')
  873. expect(a).to.not.equal(b)
  874. })
  875. it('should return different instance for same bucket different computeChecksums', function () {
  876. const a = S3Persistor._getClientForBucket('foo', false)
  877. const b = S3Persistor._getClientForBucket('foo', true)
  878. expect(a).to.not.equal(b)
  879. })
  880. })
  881. })