GcsPersistorTests.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. const sinon = require('sinon')
  2. const chai = require('chai')
  3. const { expect } = chai
  4. const modulePath = '../../src/GcsPersistor.js'
  5. const SandboxedModule = require('sandboxed-module')
  6. const { ObjectId } = require('mongodb')
  7. const asyncPool = require('tiny-async-pool')
  8. const Errors = require('../../src/Errors')
  9. describe('GcsPersistorTests', function () {
  10. const filename = '/wombat/potato.tex'
  11. const bucket = 'womBucket'
  12. const key = 'monKey'
  13. const destKey = 'donKey'
  14. const genericError = new Error('guru meditation error')
  15. const filesSize = 33
  16. const md5 = 'ffffffff00000000ffffffff00000000'
  17. const WriteStream = 'writeStream'
  18. const redirectUrl = 'https://wombat.potato/giraffe'
  19. let Logger,
  20. Transform,
  21. Storage,
  22. Fs,
  23. GcsNotFoundError,
  24. ReadStream,
  25. Stream,
  26. GcsBucket,
  27. GcsFile,
  28. GcsPersistor,
  29. FileNotFoundError,
  30. Hash,
  31. Settings,
  32. crypto,
  33. files
  34. beforeEach(function () {
  35. Settings = {
  36. directoryKeyRegex: /^[0-9a-fA-F]{24}\/[0-9a-fA-F]{24}/,
  37. Metrics: {
  38. count: sinon.stub()
  39. }
  40. }
  41. files = [
  42. {
  43. metadata: { size: 11, md5Hash: '/////wAAAAD/////AAAAAA==' },
  44. delete: sinon.stub()
  45. },
  46. {
  47. metadata: { size: 22, md5Hash: '/////wAAAAD/////AAAAAA==' },
  48. delete: sinon.stub()
  49. }
  50. ]
  51. ReadStream = {
  52. pipe: sinon.stub().returns('readStream'),
  53. on: sinon.stub(),
  54. removeListener: sinon.stub()
  55. }
  56. ReadStream.on.withArgs('end').yields()
  57. ReadStream.on.withArgs('pipe').yields({
  58. unpipe: sinon.stub(),
  59. resume: sinon.stub(),
  60. on: sinon.stub()
  61. })
  62. Transform = class {
  63. on(event, callback) {
  64. if (event === 'readable') {
  65. callback()
  66. }
  67. }
  68. once() {}
  69. removeListener() {}
  70. }
  71. Stream = {
  72. pipeline: sinon.stub().yields(),
  73. Transform: Transform
  74. }
  75. GcsFile = {
  76. delete: sinon.stub().resolves(),
  77. createReadStream: sinon.stub().returns(ReadStream),
  78. getMetadata: sinon.stub().resolves([files[0].metadata]),
  79. createWriteStream: sinon.stub().returns(WriteStream),
  80. copy: sinon.stub().resolves(),
  81. exists: sinon.stub().resolves([true]),
  82. getSignedUrl: sinon.stub().resolves([redirectUrl])
  83. }
  84. GcsBucket = {
  85. file: sinon.stub().returns(GcsFile),
  86. getFiles: sinon.stub().resolves([files])
  87. }
  88. Storage = class {
  89. constructor() {
  90. this.interceptors = []
  91. }
  92. }
  93. Storage.prototype.bucket = sinon.stub().returns(GcsBucket)
  94. GcsNotFoundError = new Error('File not found')
  95. GcsNotFoundError.code = 404
  96. Fs = {
  97. createReadStream: sinon.stub().returns(ReadStream)
  98. }
  99. FileNotFoundError = new Error('File not found')
  100. FileNotFoundError.code = 'ENOENT'
  101. Hash = {
  102. end: sinon.stub(),
  103. read: sinon.stub().returns(md5),
  104. digest: sinon.stub().returns(md5),
  105. setEncoding: sinon.stub()
  106. }
  107. crypto = {
  108. createHash: sinon.stub().returns(Hash)
  109. }
  110. Logger = {
  111. warn: sinon.stub()
  112. }
  113. GcsPersistor = new (SandboxedModule.require(modulePath, {
  114. requires: {
  115. '@google-cloud/storage': { Storage },
  116. 'logger-sharelatex': Logger,
  117. 'tiny-async-pool': asyncPool,
  118. './Errors': Errors,
  119. fs: Fs,
  120. stream: Stream,
  121. crypto
  122. },
  123. globals: { console, Buffer }
  124. }))(Settings)
  125. })
  126. describe('getObjectStream', function () {
  127. describe('when called with valid parameters', function () {
  128. let stream
  129. beforeEach(async function () {
  130. stream = await GcsPersistor.getObjectStream(bucket, key)
  131. })
  132. it('returns a metered stream', function () {
  133. expect(stream).to.be.instanceOf(Transform)
  134. })
  135. it('fetches the right key from the right bucket', function () {
  136. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  137. expect(GcsBucket.file).to.have.been.calledWith(key)
  138. expect(GcsFile.createReadStream).to.have.been.called
  139. })
  140. it('pipes the stream through the meter', function () {
  141. expect(ReadStream.pipe).to.have.been.calledWith(
  142. sinon.match.instanceOf(Transform)
  143. )
  144. })
  145. })
  146. describe('when called with a byte range', function () {
  147. let stream
  148. beforeEach(async function () {
  149. stream = await GcsPersistor.getObjectStream(bucket, key, {
  150. start: 5,
  151. end: 10
  152. })
  153. })
  154. it('returns a metered stream', function () {
  155. expect(stream).to.be.instanceOf(Transform)
  156. })
  157. it('passes the byte range on to GCS', function () {
  158. expect(GcsFile.createReadStream).to.have.been.calledWith({
  159. start: 5,
  160. end: 10
  161. })
  162. })
  163. })
  164. describe("when the file doesn't exist", function () {
  165. let error, stream
  166. beforeEach(async function () {
  167. Transform.prototype.on = sinon.stub()
  168. ReadStream.on.withArgs('error').yields(GcsNotFoundError)
  169. try {
  170. stream = await GcsPersistor.getObjectStream(bucket, key)
  171. } catch (e) {
  172. error = e
  173. }
  174. })
  175. it('does not return a stream', function () {
  176. expect(stream).not.to.exist
  177. })
  178. it('throws a NotFoundError', function () {
  179. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  180. })
  181. it('wraps the error', function () {
  182. expect(error.cause).to.exist
  183. })
  184. it('stores the bucket and key in the error', function () {
  185. expect(error.info).to.include({ bucketName: bucket, key: key })
  186. })
  187. })
  188. describe('when Gcs encounters an unkown error', function () {
  189. let error, stream
  190. beforeEach(async function () {
  191. Transform.prototype.on = sinon.stub()
  192. ReadStream.on.withArgs('error').yields(genericError)
  193. try {
  194. stream = await GcsPersistor.getObjectStream(bucket, key)
  195. } catch (err) {
  196. error = err
  197. }
  198. })
  199. it('does not return a stream', function () {
  200. expect(stream).not.to.exist
  201. })
  202. it('throws a ReadError', function () {
  203. expect(error).to.be.an.instanceOf(Errors.ReadError)
  204. })
  205. it('wraps the error', function () {
  206. expect(error.cause).to.exist
  207. })
  208. it('stores the bucket and key in the error', function () {
  209. expect(error.info).to.include({ bucketName: bucket, key: key })
  210. })
  211. })
  212. })
  213. describe('getRedirectUrl', function () {
  214. let signedUrl
  215. beforeEach(async function () {
  216. signedUrl = await GcsPersistor.getRedirectUrl(bucket, key)
  217. })
  218. it('should request a signed URL', function () {
  219. expect(GcsFile.getSignedUrl).to.have.been.called
  220. })
  221. it('should return the url', function () {
  222. expect(signedUrl).to.equal(redirectUrl)
  223. })
  224. })
  225. describe('getObjectSize', function () {
  226. describe('when called with valid parameters', function () {
  227. let size
  228. beforeEach(async function () {
  229. size = await GcsPersistor.getObjectSize(bucket, key)
  230. })
  231. it('should return the object size', function () {
  232. expect(size).to.equal(files[0].metadata.size)
  233. })
  234. it('should pass the bucket and key to GCS', function () {
  235. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  236. expect(GcsBucket.file).to.have.been.calledWith(key)
  237. expect(GcsFile.getMetadata).to.have.been.called
  238. })
  239. })
  240. describe('when the object is not found', function () {
  241. let error
  242. beforeEach(async function () {
  243. GcsFile.getMetadata = sinon.stub().rejects(GcsNotFoundError)
  244. try {
  245. await GcsPersistor.getObjectSize(bucket, key)
  246. } catch (err) {
  247. error = err
  248. }
  249. })
  250. it('should return a NotFoundError', function () {
  251. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  252. })
  253. it('should wrap the error', function () {
  254. expect(error.cause).to.equal(GcsNotFoundError)
  255. })
  256. })
  257. describe('when GCS returns an error', function () {
  258. let error
  259. beforeEach(async function () {
  260. GcsFile.getMetadata = sinon.stub().rejects(genericError)
  261. try {
  262. await GcsPersistor.getObjectSize(bucket, key)
  263. } catch (err) {
  264. error = err
  265. }
  266. })
  267. it('should return a ReadError', function () {
  268. expect(error).to.be.an.instanceOf(Errors.ReadError)
  269. })
  270. it('should wrap the error', function () {
  271. expect(error.cause).to.equal(genericError)
  272. })
  273. })
  274. })
  275. describe('sendStream', function () {
  276. describe('with valid parameters', function () {
  277. beforeEach(async function () {
  278. return GcsPersistor.sendStream(bucket, key, ReadStream)
  279. })
  280. it('should upload the stream', function () {
  281. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  282. expect(GcsBucket.file).to.have.been.calledWith(key)
  283. expect(GcsFile.createWriteStream).to.have.been.called
  284. })
  285. it('should not try to create a resumable upload', function () {
  286. expect(GcsFile.createWriteStream).to.have.been.calledWith({
  287. resumable: false
  288. })
  289. })
  290. it('should meter the stream and pass it to GCS', function () {
  291. expect(Stream.pipeline).to.have.been.calledWith(
  292. ReadStream,
  293. sinon.match.instanceOf(Transform),
  294. WriteStream
  295. )
  296. })
  297. it('calculates the md5 hash of the file', function () {
  298. expect(Hash.digest).to.have.been.called
  299. })
  300. })
  301. describe('when a hash is supplied', function () {
  302. beforeEach(async function () {
  303. return GcsPersistor.sendStream(bucket, key, ReadStream, {
  304. sourceMd5: 'aaaaaaaabbbbbbbbaaaaaaaabbbbbbbb'
  305. })
  306. })
  307. it('should not calculate the md5 hash of the file', function () {
  308. expect(Hash.digest).not.to.have.been.called
  309. })
  310. it('sends the hash in base64', function () {
  311. expect(GcsFile.createWriteStream).to.have.been.calledWith({
  312. validation: 'md5',
  313. metadata: {
  314. md5Hash: 'qqqqqru7u7uqqqqqu7u7uw=='
  315. },
  316. resumable: false
  317. })
  318. })
  319. it('does not fetch the md5 hash of the uploaded file', function () {
  320. expect(GcsFile.getMetadata).not.to.have.been.called
  321. })
  322. })
  323. describe('when metadata is supplied', function () {
  324. const contentType = 'text/csv'
  325. const contentEncoding = 'gzip'
  326. beforeEach(async function () {
  327. return GcsPersistor.sendStream(bucket, key, ReadStream, {
  328. contentType,
  329. contentEncoding
  330. })
  331. })
  332. it('should send the metadata to GCS', function () {
  333. expect(GcsFile.createWriteStream).to.have.been.calledWith({
  334. metadata: { contentType, contentEncoding },
  335. resumable: false
  336. })
  337. })
  338. })
  339. describe('when the upload fails', function () {
  340. let error
  341. beforeEach(async function () {
  342. Stream.pipeline
  343. .withArgs(
  344. ReadStream,
  345. sinon.match.instanceOf(Transform),
  346. WriteStream,
  347. sinon.match.any
  348. )
  349. .yields(genericError)
  350. try {
  351. await GcsPersistor.sendStream(bucket, key, ReadStream)
  352. } catch (err) {
  353. error = err
  354. }
  355. })
  356. it('throws a WriteError', function () {
  357. expect(error).to.be.an.instanceOf(Errors.WriteError)
  358. })
  359. it('wraps the error', function () {
  360. expect(error.cause).to.equal(genericError)
  361. })
  362. })
  363. })
  364. describe('sendFile', function () {
  365. describe('with valid parameters', function () {
  366. beforeEach(async function () {
  367. return GcsPersistor.sendFile(bucket, key, filename)
  368. })
  369. it('should create a read stream for the file', function () {
  370. expect(Fs.createReadStream).to.have.been.calledWith(filename)
  371. })
  372. it('should create a write stream', function () {
  373. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  374. expect(GcsBucket.file).to.have.been.calledWith(key)
  375. expect(GcsFile.createWriteStream).to.have.been.called
  376. })
  377. it('should upload the stream via the meter', function () {
  378. expect(Stream.pipeline).to.have.been.calledWith(
  379. ReadStream,
  380. sinon.match.instanceOf(Transform),
  381. WriteStream
  382. )
  383. })
  384. })
  385. })
  386. describe('copyObject', function () {
  387. const destinationFile = 'destFile'
  388. beforeEach(function () {
  389. GcsBucket.file.withArgs(destKey).returns(destinationFile)
  390. })
  391. describe('with valid parameters', function () {
  392. beforeEach(async function () {
  393. return GcsPersistor.copyObject(bucket, key, destKey)
  394. })
  395. it('should copy the object', function () {
  396. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  397. expect(GcsBucket.file).to.have.been.calledWith(key)
  398. expect(GcsFile.copy).to.have.been.calledWith(destinationFile)
  399. })
  400. })
  401. describe('when the file does not exist', function () {
  402. let error
  403. beforeEach(async function () {
  404. GcsFile.copy = sinon.stub().rejects(GcsNotFoundError)
  405. try {
  406. await GcsPersistor.copyObject(bucket, key, destKey)
  407. } catch (err) {
  408. error = err
  409. }
  410. })
  411. it('should throw a NotFoundError', function () {
  412. expect(error).to.be.an.instanceOf(Errors.NotFoundError)
  413. })
  414. })
  415. })
  416. describe('deleteObject', function () {
  417. describe('with valid parameters', function () {
  418. beforeEach(async function () {
  419. return GcsPersistor.deleteObject(bucket, key)
  420. })
  421. it('should delete the object', function () {
  422. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  423. expect(GcsBucket.file).to.have.been.calledWith(key)
  424. expect(GcsFile.delete).to.have.been.called
  425. })
  426. })
  427. describe('when the file does not exist', function () {
  428. let error
  429. beforeEach(async function () {
  430. GcsFile.delete = sinon.stub().rejects(GcsNotFoundError)
  431. try {
  432. await GcsPersistor.deleteObject(bucket, key)
  433. } catch (err) {
  434. error = err
  435. }
  436. })
  437. it('should not throw an error', function () {
  438. expect(error).not.to.exist
  439. })
  440. })
  441. })
  442. describe('deleteDirectory', function () {
  443. const directoryName = `${ObjectId()}/${ObjectId()}`
  444. describe('with valid parameters', function () {
  445. beforeEach(async function () {
  446. return GcsPersistor.deleteDirectory(bucket, directoryName)
  447. })
  448. it('should list the objects in the directory', function () {
  449. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  450. expect(GcsBucket.getFiles).to.have.been.calledWith({
  451. directory: directoryName
  452. })
  453. })
  454. it('should delete the files', function () {
  455. expect(GcsFile.delete).to.have.been.calledTwice
  456. })
  457. })
  458. describe('when there is an error listing the objects', function () {
  459. let error
  460. beforeEach(async function () {
  461. GcsBucket.getFiles = sinon.stub().rejects(genericError)
  462. try {
  463. await GcsPersistor.deleteDirectory(bucket, directoryName)
  464. } catch (err) {
  465. error = err
  466. }
  467. })
  468. it('should generate a WriteError', function () {
  469. expect(error).to.be.an.instanceOf(Errors.WriteError)
  470. })
  471. it('should wrap the error', function () {
  472. expect(error.cause).to.equal(genericError)
  473. })
  474. })
  475. })
  476. describe('directorySize', function () {
  477. describe('with valid parameters', function () {
  478. let size
  479. beforeEach(async function () {
  480. size = await GcsPersistor.directorySize(bucket, key)
  481. })
  482. it('should list the objects in the directory', function () {
  483. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  484. expect(GcsBucket.getFiles).to.have.been.calledWith({ directory: key })
  485. })
  486. it('should return the directory size', function () {
  487. expect(size).to.equal(filesSize)
  488. })
  489. })
  490. describe('when there are no files', function () {
  491. let size
  492. beforeEach(async function () {
  493. GcsBucket.getFiles.resolves([[]])
  494. size = await GcsPersistor.directorySize(bucket, key)
  495. })
  496. it('should list the objects in the directory', function () {
  497. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  498. expect(GcsBucket.getFiles).to.have.been.calledWith({ directory: key })
  499. })
  500. it('should return zero', function () {
  501. expect(size).to.equal(0)
  502. })
  503. })
  504. describe('when there is an error listing the objects', function () {
  505. let error
  506. beforeEach(async function () {
  507. GcsBucket.getFiles.rejects(genericError)
  508. try {
  509. await GcsPersistor.directorySize(bucket, key)
  510. } catch (err) {
  511. error = err
  512. }
  513. })
  514. it('should generate a ReadError', function () {
  515. expect(error).to.be.an.instanceOf(Errors.ReadError)
  516. })
  517. it('should wrap the error', function () {
  518. expect(error.cause).to.equal(genericError)
  519. })
  520. })
  521. })
  522. describe('checkIfObjectExists', function () {
  523. describe('when the file exists', function () {
  524. let exists
  525. beforeEach(async function () {
  526. exists = await GcsPersistor.checkIfObjectExists(bucket, key)
  527. })
  528. it('should ask the file if it exists', function () {
  529. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  530. expect(GcsBucket.file).to.have.been.calledWith(key)
  531. expect(GcsFile.exists).to.have.been.called
  532. })
  533. it('should return that the file exists', function () {
  534. expect(exists).to.equal(true)
  535. })
  536. })
  537. describe('when the file does not exist', function () {
  538. let exists
  539. beforeEach(async function () {
  540. GcsFile.exists = sinon.stub().resolves([false])
  541. exists = await GcsPersistor.checkIfObjectExists(bucket, key)
  542. })
  543. it('should get the object header', function () {
  544. expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
  545. expect(GcsBucket.file).to.have.been.calledWith(key)
  546. expect(GcsFile.exists).to.have.been.called
  547. })
  548. it('should return that the file does not exist', function () {
  549. expect(exists).to.equal(false)
  550. })
  551. })
  552. describe('when there is an error', function () {
  553. let error
  554. beforeEach(async function () {
  555. GcsFile.exists = sinon.stub().rejects(genericError)
  556. try {
  557. await GcsPersistor.checkIfObjectExists(bucket, key)
  558. } catch (err) {
  559. error = err
  560. }
  561. })
  562. it('should generate a ReadError', function () {
  563. expect(error).to.be.an.instanceOf(Errors.ReadError)
  564. })
  565. it('should wrap the error', function () {
  566. expect(error.cause).to.equal(genericError)
  567. })
  568. })
  569. })
  570. })