S3PersistorTests.js 29 KB

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