GcsPersistorTests.js 20 KB

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