Explorar o código

Add unit tests for GCS Persistor

Simon Detheridge %!s(int64=6) %!d(string=hai) anos
pai
achega
12274e1427
Modificáronse 1 ficheiros con 756 adicións e 0 borrados
  1. 756 0
      services/filestore/test/unit/js/GcsPersistorTests.js

+ 756 - 0
services/filestore/test/unit/js/GcsPersistorTests.js

@@ -0,0 +1,756 @@
+const sinon = require('sinon')
+const chai = require('chai')
+const { expect } = chai
+const modulePath = '../../../app/js/GcsPersistor.js'
+const SandboxedModule = require('sandboxed-module')
+
+const Errors = require('../../../app/js/Errors')
+
+describe('GcsPersistorTests', function() {
+  const filename = '/wombat/potato.tex'
+  const bucket = 'womBucket'
+  const key = 'monKey'
+  const destKey = 'donKey'
+  const objectSize = 5555
+  const genericError = new Error('guru meditation error')
+  const filesSize = 33
+  const md5 = 'ffffffff00000000ffffffff00000000'
+  const WriteStream = 'writeStream'
+
+  let Metrics,
+    Logger,
+    Storage,
+    Fs,
+    NotFoundError,
+    Meter,
+    MeteredStream,
+    ReadStream,
+    Stream,
+    GcsBucket,
+    GcsFile,
+    GcsPersistor,
+    FileNotFoundError,
+    Hash,
+    settings,
+    crypto,
+    files
+
+  beforeEach(function() {
+    settings = {
+      filestore: {
+        backend: 'gcs',
+        stores: {
+          user_files: 'user_files'
+        }
+      }
+    }
+
+    files = [
+      {
+        metadata: { size: 11, md5Hash: '/////wAAAAD/////AAAAAA==' },
+        delete: sinon.stub()
+      },
+      {
+        metadata: { size: 22, md5Hash: '/////wAAAAD/////AAAAAA==' },
+        delete: sinon.stub()
+      }
+    ]
+
+    ReadStream = {
+      pipe: sinon.stub().returns('readStream'),
+      on: sinon
+        .stub()
+        .withArgs('end')
+        .yields(),
+      removeListener: sinon.stub()
+    }
+
+    Stream = {
+      pipeline: sinon.stub().yields()
+    }
+
+    Metrics = {
+      count: sinon.stub()
+    }
+
+    GcsFile = {
+      delete: sinon.stub().resolves(),
+      createReadStream: sinon.stub().returns(ReadStream),
+      getMetadata: sinon.stub().resolves([files[0].metadata]),
+      createWriteStream: sinon.stub().returns(WriteStream),
+      copy: sinon.stub().resolves(),
+      exists: sinon.stub().resolves([true])
+    }
+
+    GcsBucket = {
+      file: sinon.stub().returns(GcsFile),
+      getFiles: sinon.stub().resolves([files])
+    }
+
+    Storage = class {
+      constructor() {
+        this.interceptors = []
+      }
+    }
+    Storage.prototype.bucket = sinon.stub().returns(GcsBucket)
+
+    NotFoundError = new Error('File not found')
+    NotFoundError.code = 404
+
+    Fs = {
+      createReadStream: sinon.stub().returns(ReadStream)
+    }
+
+    FileNotFoundError = new Error('File not found')
+    FileNotFoundError.code = 'ENOENT'
+
+    MeteredStream = {
+      type: 'metered',
+      on: sinon.stub(),
+      bytes: objectSize
+    }
+    MeteredStream.on.withArgs('finish').yields()
+    MeteredStream.on.withArgs('readable').yields()
+    Meter = sinon.stub().returns(MeteredStream)
+
+    Hash = {
+      end: sinon.stub(),
+      read: sinon.stub().returns(md5),
+      setEncoding: sinon.stub()
+    }
+    crypto = {
+      createHash: sinon.stub().returns(Hash)
+    }
+
+    Logger = {
+      warn: sinon.stub()
+    }
+
+    GcsPersistor = SandboxedModule.require(modulePath, {
+      requires: {
+        '@google-cloud/storage': { Storage },
+        'settings-sharelatex': settings,
+        'logger-sharelatex': Logger,
+        './Errors': Errors,
+        fs: Fs,
+        'stream-meter': Meter,
+        stream: Stream,
+        'metrics-sharelatex': Metrics,
+        crypto
+      },
+      globals: { console }
+    })
+  })
+
+  describe('getFileStream', function() {
+    describe('when called with valid parameters', function() {
+      let stream
+
+      beforeEach(async function() {
+        stream = await GcsPersistor.promises.getFileStream(bucket, key)
+      })
+
+      it('returns a metered stream', function() {
+        expect(stream).to.equal(MeteredStream)
+      })
+
+      it('fetches the right key from the right bucket', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.createReadStream).to.have.been.called
+      })
+
+      it('pipes the stream through the meter', function() {
+        expect(Stream.pipeline).to.have.been.calledWith(
+          ReadStream,
+          MeteredStream
+        )
+      })
+
+      it('records an ingress metric', function() {
+        expect(Metrics.count).to.have.been.calledWith('gcs.ingress', objectSize)
+      })
+    })
+
+    describe('when called with a byte range', function() {
+      let stream
+
+      beforeEach(async function() {
+        stream = await GcsPersistor.promises.getFileStream(bucket, key, {
+          start: 5,
+          end: 10
+        })
+      })
+
+      it('returns a metered stream', function() {
+        expect(stream).to.equal(MeteredStream)
+      })
+
+      it('passes the byte range on to GCS', function() {
+        expect(GcsFile.createReadStream).to.have.been.calledWith({
+          start: 5,
+          end: 11 // we increment the end because Google's 'end' is exclusive
+        })
+      })
+    })
+
+    describe("when the file doesn't exist", function() {
+      let error, stream
+
+      beforeEach(async function() {
+        ReadStream.on = sinon.stub()
+        ReadStream.on.withArgs('error').yields(NotFoundError)
+        try {
+          stream = await GcsPersistor.promises.getFileStream(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('does not return a stream', function() {
+        expect(stream).not.to.exist
+      })
+
+      it('throws a NotFoundError', function() {
+        expect(error).to.be.an.instanceOf(Errors.NotFoundError)
+      })
+
+      it('wraps the error', function() {
+        expect(error.cause).to.exist
+      })
+
+      it('stores the bucket and key in the error', function() {
+        expect(error.info).to.include({ bucket: bucket, key: key })
+      })
+    })
+
+    describe('when Gcs encounters an unkown error', function() {
+      let error, stream
+
+      beforeEach(async function() {
+        ReadStream.on = sinon.stub()
+        ReadStream.on.withArgs('error').yields(genericError)
+        try {
+          stream = await GcsPersistor.promises.getFileStream(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('does not return a stream', function() {
+        expect(stream).not.to.exist
+      })
+
+      it('throws a ReadError', function() {
+        expect(error).to.be.an.instanceOf(Errors.ReadError)
+      })
+
+      it('wraps the error', function() {
+        expect(error.cause).to.exist
+      })
+
+      it('stores the bucket and key in the error', function() {
+        expect(error.info).to.include({ bucket: bucket, key: key })
+      })
+    })
+  })
+
+  describe('getFileSize', function() {
+    describe('when called with valid parameters', function() {
+      let size
+
+      beforeEach(async function() {
+        size = await GcsPersistor.promises.getFileSize(bucket, key)
+      })
+
+      it('should return the object size', function() {
+        expect(size).to.equal(files[0].metadata.size)
+      })
+
+      it('should pass the bucket and key to GCS', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.getMetadata).to.have.been.called
+      })
+    })
+
+    describe('when the object is not found', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsFile.getMetadata = sinon.stub().rejects(NotFoundError)
+        try {
+          await GcsPersistor.promises.getFileSize(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should return a NotFoundError', function() {
+        expect(error).to.be.an.instanceOf(Errors.NotFoundError)
+      })
+
+      it('should wrap the error', function() {
+        expect(error.cause).to.equal(NotFoundError)
+      })
+    })
+
+    describe('when GCS returns an error', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsFile.getMetadata = sinon.stub().rejects(genericError)
+        try {
+          await GcsPersistor.promises.getFileSize(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should return a ReadError', function() {
+        expect(error).to.be.an.instanceOf(Errors.ReadError)
+      })
+
+      it('should wrap the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+    })
+  })
+
+  describe('sendStream', function() {
+    describe('with valid parameters', function() {
+      beforeEach(async function() {
+        return GcsPersistor.promises.sendStream(bucket, key, ReadStream)
+      })
+
+      it('should upload the stream', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.createWriteStream).to.have.been.called
+      })
+
+      it('should not try to create a resumable upload', function() {
+        expect(GcsFile.createWriteStream).to.have.been.calledWith({
+          resumable: false
+        })
+      })
+
+      it('should meter the stream', function() {
+        expect(Stream.pipeline).to.have.been.calledWith(
+          ReadStream,
+          MeteredStream
+        )
+      })
+
+      it('should pipe the metered stream to GCS', function() {
+        expect(Stream.pipeline).to.have.been.calledWith(
+          MeteredStream,
+          WriteStream
+        )
+      })
+
+      it('should record an egress metric', function() {
+        expect(Metrics.count).to.have.been.calledWith('gcs.egress', objectSize)
+      })
+
+      it('calculates the md5 hash of the file', function() {
+        expect(Stream.pipeline).to.have.been.calledWith(ReadStream, Hash)
+      })
+    })
+
+    describe('when a hash is supplied', function() {
+      beforeEach(async function() {
+        return GcsPersistor.promises.sendStream(
+          bucket,
+          key,
+          ReadStream,
+          'aaaaaaaabbbbbbbbaaaaaaaabbbbbbbb'
+        )
+      })
+
+      it('should not calculate the md5 hash of the file', function() {
+        expect(Stream.pipeline).not.to.have.been.calledWith(
+          sinon.match.any,
+          Hash
+        )
+      })
+
+      it('sends the hash in base64', function() {
+        expect(GcsFile.createWriteStream).to.have.been.calledWith({
+          validation: 'md5',
+          metadata: {
+            md5Hash: 'qqqqqru7u7uqqqqqu7u7uw=='
+          },
+          resumable: false
+        })
+      })
+
+      it('does not fetch the md5 hash of the uploaded file', function() {
+        expect(GcsFile.getMetadata).not.to.have.been.called
+      })
+    })
+
+    describe('when the upload fails', function() {
+      let error
+      beforeEach(async function() {
+        Stream.pipeline
+          .withArgs(MeteredStream, WriteStream, sinon.match.any)
+          .yields(genericError)
+        try {
+          await GcsPersistor.promises.sendStream(bucket, key, ReadStream)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('throws a WriteError', function() {
+        expect(error).to.be.an.instanceOf(Errors.WriteError)
+      })
+
+      it('wraps the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+    })
+  })
+
+  describe('sendFile', function() {
+    describe('with valid parameters', function() {
+      beforeEach(async function() {
+        return GcsPersistor.promises.sendFile(bucket, key, filename)
+      })
+
+      it('should create a read stream for the file', function() {
+        expect(Fs.createReadStream).to.have.been.calledWith(filename)
+      })
+
+      it('should create a write stream', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.createWriteStream).to.have.been.called
+      })
+
+      it('should upload the stream via the meter', function() {
+        expect(Stream.pipeline).to.have.been.calledWith(
+          ReadStream,
+          MeteredStream
+        )
+        expect(Stream.pipeline).to.have.been.calledWith(
+          MeteredStream,
+          WriteStream
+        )
+      })
+    })
+
+    describe('when the file does not exist', function() {
+      let error
+
+      beforeEach(async function() {
+        Fs.createReadStream = sinon.stub().throws(FileNotFoundError)
+        try {
+          await GcsPersistor.promises.sendFile(bucket, key, filename)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('returns a NotFoundError', function() {
+        expect(error).to.be.an.instanceOf(Errors.NotFoundError)
+      })
+
+      it('wraps the error', function() {
+        expect(error.cause).to.equal(FileNotFoundError)
+      })
+    })
+
+    describe('when reading the file throws an error', function() {
+      let error
+
+      beforeEach(async function() {
+        Fs.createReadStream = sinon.stub().throws(genericError)
+        try {
+          await GcsPersistor.promises.sendFile(bucket, key, filename)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('returns a ReadError', function() {
+        expect(error).to.be.an.instanceOf(Errors.ReadError)
+      })
+
+      it('wraps the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+    })
+  })
+
+  describe('copyFile', function() {
+    const DestinationFile = 'destFile'
+
+    beforeEach(function() {
+      GcsBucket.file.withArgs(destKey).returns(DestinationFile)
+    })
+
+    describe('with valid parameters', function() {
+      beforeEach(async function() {
+        return GcsPersistor.promises.copyFile(bucket, key, destKey)
+      })
+
+      it('should copy the object', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.copy).to.have.been.calledWith(DestinationFile)
+      })
+    })
+
+    describe('when the file does not exist', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsFile.copy = sinon.stub().rejects(NotFoundError)
+        try {
+          await GcsPersistor.promises.copyFile(bucket, key, destKey)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should throw a NotFoundError', function() {
+        expect(error).to.be.an.instanceOf(Errors.NotFoundError)
+      })
+    })
+  })
+
+  describe('deleteFile', function() {
+    describe('with valid parameters', function() {
+      beforeEach(async function() {
+        return GcsPersistor.promises.deleteFile(bucket, key)
+      })
+
+      it('should delete the object', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.delete).to.have.been.called
+      })
+    })
+
+    describe('when the file does not exist', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsFile.delete = sinon.stub().rejects(NotFoundError)
+        try {
+          await GcsPersistor.promises.deleteFile(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should not throw an error', function() {
+        expect(error).not.to.exist
+      })
+    })
+  })
+
+  describe('deleteDirectory', function() {
+    describe('with valid parameters', function() {
+      beforeEach(async function() {
+        return GcsPersistor.promises.deleteDirectory(bucket, key)
+      })
+
+      it('should list the objects in the directory', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.getFiles).to.have.been.calledWith({
+          directory: key
+        })
+      })
+
+      it('should delete the objects', function() {
+        expect(files[0].delete).to.have.been.called
+        expect(files[1].delete).to.have.been.called
+      })
+    })
+
+    describe('when there are no files', function() {
+      beforeEach(async function() {
+        GcsBucket.getFiles.resolves([[]])
+        return GcsPersistor.promises.deleteDirectory(bucket, key)
+      })
+
+      it('should list the objects in the directory', function() {
+        expect(GcsBucket.getFiles).to.have.been.calledWith({
+          directory: key
+        })
+      })
+
+      it('should not try to delete any objects', function() {
+        expect(files[0].delete).not.to.have.been.called
+        expect(files[1].delete).not.to.have.been.called
+      })
+    })
+
+    describe('when there is an error listing the objects', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsBucket.getFiles = sinon.stub().rejects(genericError)
+        try {
+          await GcsPersistor.promises.deleteDirectory(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should generate a ReadError', function() {
+        expect(error).to.be.an.instanceOf(Errors.ReadError)
+      })
+
+      it('should wrap the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+
+      it('should not try to delete any objects', function() {
+        expect(files[0].delete).not.to.have.been.called
+      })
+    })
+
+    describe('when there is an error deleting the objects', function() {
+      let error
+
+      beforeEach(async function() {
+        files[0].delete = sinon.stub().rejects(genericError)
+        try {
+          await GcsPersistor.promises.deleteDirectory(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should generate a WriteError', function() {
+        expect(error).to.be.an.instanceOf(Errors.WriteError)
+      })
+
+      it('should wrap the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+    })
+  })
+
+  describe('directorySize', function() {
+    describe('with valid parameters', function() {
+      let size
+
+      beforeEach(async function() {
+        size = await GcsPersistor.promises.directorySize(bucket, key)
+      })
+
+      it('should list the objects in the directory', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.getFiles).to.have.been.calledWith({ directory: key })
+      })
+
+      it('should return the directory size', function() {
+        expect(size).to.equal(filesSize)
+      })
+    })
+
+    describe('when there are no files', function() {
+      let size
+
+      beforeEach(async function() {
+        GcsBucket.getFiles.resolves([[]])
+        size = await GcsPersistor.promises.directorySize(bucket, key)
+      })
+
+      it('should list the objects in the directory', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.getFiles).to.have.been.calledWith({ directory: key })
+      })
+
+      it('should return zero', function() {
+        expect(size).to.equal(0)
+      })
+    })
+
+    describe('when there is an error listing the objects', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsBucket.getFiles.rejects(genericError)
+        try {
+          await GcsPersistor.promises.directorySize(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should generate a ReadError', function() {
+        expect(error).to.be.an.instanceOf(Errors.ReadError)
+      })
+
+      it('should wrap the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+    })
+  })
+
+  describe('checkIfFileExists', function() {
+    describe('when the file exists', function() {
+      let exists
+
+      beforeEach(async function() {
+        exists = await GcsPersistor.promises.checkIfFileExists(bucket, key)
+      })
+
+      it('should ask the file if it exists', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.exists).to.have.been.called
+      })
+
+      it('should return that the file exists', function() {
+        expect(exists).to.equal(true)
+      })
+    })
+
+    describe('when the file does not exist', function() {
+      let exists
+
+      beforeEach(async function() {
+        GcsFile.exists = sinon.stub().resolves([false])
+        exists = await GcsPersistor.promises.checkIfFileExists(bucket, key)
+      })
+
+      it('should get the object header', function() {
+        expect(Storage.prototype.bucket).to.have.been.calledWith(bucket)
+        expect(GcsBucket.file).to.have.been.calledWith(key)
+        expect(GcsFile.exists).to.have.been.called
+      })
+
+      it('should return that the file does not exist', function() {
+        expect(exists).to.equal(false)
+      })
+    })
+
+    describe('when there is an error', function() {
+      let error
+
+      beforeEach(async function() {
+        GcsFile.exists = sinon.stub().rejects(genericError)
+        try {
+          await GcsPersistor.promises.checkIfFileExists(bucket, key)
+        } catch (err) {
+          error = err
+        }
+      })
+
+      it('should generate a ReadError', function() {
+        expect(error).to.be.an.instanceOf(Errors.ReadError)
+      })
+
+      it('should wrap the error', function() {
+        expect(error.cause).to.equal(genericError)
+      })
+    })
+  })
+})